From 82a838ad8a6afbf16a52a963b600b32da95ce8c4 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 5 Aug 2026 01:39:21 +0900 Subject: [PATCH 1/2] feat(telegram): harden durable topic authority --- docs/telegram-onboarding.md | 1 + packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/config/file-lock-gc.ts | 18 + packages/coding-agent/src/config/file-lock.ts | 77 +- .../src/internal-urls/docs-index.generated.ts | 2 +- .../src/sdk/bus/telegram-daemon-cli.ts | 11 + .../src/sdk/bus/telegram-daemon-contract.ts | 15 +- .../src/sdk/bus/telegram-daemon.ts | 1652 +++++++++++++---- .../src/sdk/bus/topic-registry.ts | 855 +++++++-- .../coding-agent/test/daemon-control.test.ts | 1 + .../test/file-lock-gc-toctou.test.ts | 43 +- .../test/manifests/telegram-baseline-v1.json | 21 + .../test/notifications-config.test.ts | 1 + .../test/notifications-rich-e2e.test.ts | 8 +- ...tions-telegram-daemon-2960-redteam.test.ts | 39 +- ...notifications-telegram-daemon-2960.test.ts | 200 +- .../notifications-telegram-daemon-cas.test.ts | 355 ++++ .../notifications-telegram-daemon.test.ts | 1049 ++++++++--- .../test/notifications-topic-registry.test.ts | 461 +++-- ...fications-topic-settle-fence-epoch.test.ts | 313 +--- .../telegram-daemon-generation-manifest.json | 32 +- 21 files changed, 3883 insertions(+), 1273 deletions(-) create mode 100644 packages/coding-agent/test/notifications-telegram-daemon-cas.test.ts diff --git a/docs/telegram-onboarding.md b/docs/telegram-onboarding.md index c61338ff02..7f7d5a4b33 100644 --- a/docs/telegram-onboarding.md +++ b/docs/telegram-onboarding.md @@ -79,6 +79,7 @@ The setup pairing flow is private-chat only. If setup sees a `group`, DM. This is intentional for safe local discovery: group chats must not receive session names, action ids, or pending status by accident. + Telegram private-chat topics: the managed daemon's per-session delivery uses Telegram forum topics (`createForumTopic` + `message_thread_id`). Telegram now supports forum topics in **private chats** when the bot owner enables **Threaded diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index aa8bbdc952..ac51c5dd86 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -171,6 +171,8 @@ - User-created Telegram forum topics can now start a GJC session by selecting the home folder, choosing a verified recent work folder, or entering an explicit folder path. The selected topic is adopted by the new session without creating or deleting a separate Telegram topic. - The interactive terminal’s responsive IRC/todo work-lane contract now covers exact narrow/wide geometry, requested versus effective IRC visibility, direct-root pin ordering, todo lane bounds, remapped IRC toggles, and live composer shortcut hints. +- Managed-session startup now preserves bounded Windows ACL and identity failure classifications in path-redacted recovery guidance without broadening permissions, elevation, or unsafe fallback. +- Telegram topic synchronization now uses generation-CAS shared authority, durable pre-create claims, lease-fenced effects, bounded single-flight archive retries, and an isolated owner-backed validation-supergroup mode without deleting topics. ### Fixed diff --git a/packages/coding-agent/src/config/file-lock-gc.ts b/packages/coding-agent/src/config/file-lock-gc.ts index 4c9cc0d232..00afcb52f3 100644 --- a/packages/coding-agent/src/config/file-lock-gc.ts +++ b/packages/coding-agent/src/config/file-lock-gc.ts @@ -61,6 +61,21 @@ function keptMalformedRecord(lockDir: string): GcRecord { async function collectLockRecord(lockDir: string, ctx: GcContext): Promise { const info = await readFileLockInfoForGc(lockDir); if (!info) return keptMalformedRecord(lockDir); + if (info.owner_host_id !== undefined) { + return { + store: "file_locks", + id: lockDir, + path: lockDir, + pid: info.pid, + pid_status: "unknown", + status: "host_qualified", + stale: false, + removable: false, + action: "none", + reason: "host_qualified_lock_requires_owner_reclamation", + detail: `timestamp=${info.timestamp}`, + }; + } const probeResult = ctx.probe(info.pid); const pidStatus = gcPidStatusLabel(probeResult); @@ -166,6 +181,9 @@ export const fileLocksGcAdapter: GcStoreAdapter = { const lockDir = record.path ?? record.id; const info = await readFileLockInfoForGc(lockDir); if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" }; + if (info.owner_host_id !== undefined) { + return { removed: false, skipped: "host_qualified_lock_requires_owner_reclamation" }; + } const probeResult = ctx.probe(info.pid); if (probeResult.status !== "dead") { diff --git a/packages/coding-agent/src/config/file-lock.ts b/packages/coding-agent/src/config/file-lock.ts index 7330d248c0..70879f3771 100644 --- a/packages/coding-agent/src/config/file-lock.ts +++ b/packages/coding-agent/src/config/file-lock.ts @@ -1,3 +1,4 @@ +import * as crypto from "node:crypto"; import type { Stats } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -7,9 +8,11 @@ export interface FileLockOptions { staleMs?: number; retries?: number; retryDelayMs?: number; + /** Stable host identity required to safely reclaim locks on a shared volume. */ + ownerHostId?: string; } -const DEFAULT_OPTIONS: Required = { +const DEFAULT_OPTIONS: Required> = { staleMs: 10_000, retries: 50, retryDelayMs: 100, @@ -61,8 +64,16 @@ function ownerIsAlive(owner: FileLockOwnerToken, startTimeCache?: Map { - const info: LockInfo = { pid: process.pid, start_time: currentProcessStartTime(), timestamp: Date.now() }; +function lockInfo(ownerHostId?: string): LockInfo { + return { + pid: process.pid, + start_time: currentProcessStartTime(), + timestamp: Date.now(), + ...(ownerHostId === undefined ? {} : { owner_host_id: ownerHostId }), + }; +} + +function writeLockInfo(lockPath: string, info: LockInfo): Promise { return Bun.write(`${lockPath}/info`, JSON.stringify(info)).then(() => info); } @@ -76,17 +87,18 @@ async function readLockInfo(lockPath: string): Promise { } if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null; - const { pid, start_time, timestamp } = parsed as Partial; + const { pid, start_time, timestamp, owner_host_id } = parsed as Partial; if ( typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0 || typeof timestamp !== "number" || !Number.isFinite(timestamp) || - (start_time !== undefined && (typeof start_time !== "string" || !start_time)) + (start_time !== undefined && (typeof start_time !== "string" || !start_time)) || + (owner_host_id !== undefined && (typeof owner_host_id !== "string" || !owner_host_id)) ) return null; - return { pid, start_time, timestamp }; + return { pid, start_time, timestamp, owner_host_id }; } /** @internal */ @@ -98,7 +110,7 @@ export async function readFileLockInfoForGc(lockDir: string): Promise, ): Promise { let info: LockInfo | null; @@ -199,6 +213,7 @@ async function staleLockSnapshot( if (hasFsCode(error, "EPERM")) return { stale: false }; throw error; } + if (!info && ownerHostId !== undefined) return { stale: false }; if (!info) { try { const stats = await fs.stat(lockPath); @@ -210,6 +225,10 @@ async function staleLockSnapshot( } } + // A host-qualified lock may only be reclaimed after proving that its owner is + // local. Foreign and malformed host-qualified records fail closed: PID values + // and clocks are not meaningful across hosts. + if (ownerHostId !== undefined && info.owner_host_id !== ownerHostId) return { stale: false }; // Never reap a live owner by elapsed time: a long legitimate critical section must // not have its lock stolen (#652). Reclaim a dead owner immediately. Only when owner // liveness is indeterminate do we fall back to the staleMs elapsed-time heuristic. @@ -239,18 +258,43 @@ async function removeStaleLockForAcquire(lockPath: string, snapshot: LockStaleSn } } -async function tryAcquireLock(lockPath: string): Promise { +async function tryAcquireLock(lockPath: string, ownerHostId?: string): Promise { await fs.mkdir(path.dirname(lockPath), { recursive: true }); const afterParentMkdir = FileLockTestHooks.afterParentMkdir; if (afterParentMkdir) await afterParentMkdir(lockPath); + if (ownerHostId === undefined) { + try { + await fs.mkdir(lockPath); + return await writeLockInfo(lockPath, lockInfo()); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return null; + throw error; + } + } + + const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`; + const owner = lockInfo(ownerHostId); try { - await fs.mkdir(lockPath); - return await writeLockInfo(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - return null; + await fs.mkdir(pendingPath); + await writeLockInfo(pendingPath, owner); + try { + await fs.rename(pendingPath, lockPath); + return owner; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EEXIST" || code === "ENOTEMPTY") return null; + if (code === "EPERM") { + try { + await fs.stat(lockPath); + return null; + } catch (statError) { + if (!isEnoent(statError)) throw statError; + } + } + throw error; } - throw error; + } finally { + await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined); } } @@ -259,14 +303,15 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`); } async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise> { + if (options.ownerHostId !== undefined && !options.ownerHostId) throw new Error("ownerHostId must be non-empty"); const opts = { ...DEFAULT_OPTIONS, ...options }; const lockPath = getLockPath(filePath); const contentionStartTimes = new Map(); for (let attempt = 0; attempt < opts.retries; attempt++) { - const owner = await tryAcquireLock(lockPath); + const owner = await tryAcquireLock(lockPath, opts.ownerHostId); if (owner) return () => releaseLock(lockPath, owner); - const stale = await staleLockSnapshot(lockPath, opts.staleMs, contentionStartTimes); + const stale = await staleLockSnapshot(lockPath, opts.staleMs, opts.ownerHostId, contentionStartTimes); if (await removeStaleLockForAcquire(lockPath, stale)) continue; await Bun.sleep(opts.retryDelayMs); } diff --git a/packages/coding-agent/src/internal-urls/docs-index.generated.ts b/packages/coding-agent/src/internal-urls/docs-index.generated.ts index c79db7c845..20a9ee4c09 100644 --- a/packages/coding-agent/src/internal-urls/docs-index.generated.ts +++ b/packages/coding-agent/src/internal-urls/docs-index.generated.ts @@ -86,7 +86,7 @@ export const EMBEDDED_DOCS: Readonly> = { "session.md": "# Session Storage and Entry Model\n\nThis document is the source of truth for how coding-agent sessions are represented, persisted, migrated, and reconstructed at runtime.\n\n## Scope\n\nCovers:\n\n- Session JSONL format and versioning\n- Entry taxonomy and tree semantics (`id`/`parentId` + leaf pointer)\n- Migration/compatibility behavior when loading old or malformed files\n- Context reconstruction (`buildSessionContext`)\n- Persistence guarantees, failure behavior, truncation/blob externalization\n- Storage abstractions (`FileSessionStorage`, `MemorySessionStorage`) and related utilities\n\nDoes not cover `/tree` UI rendering behavior beyond semantics that affect session data.\n\n## Implementation Files\n\n- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)\n- [`src/session/messages.ts`](../packages/coding-agent/src/session/messages.ts)\n- [`src/session/session-storage.ts`](../packages/coding-agent/src/session/session-storage.ts)\n- [`src/session/history-storage.ts`](../packages/coding-agent/src/session/history-storage.ts)\n- [`src/session/blob-store.ts`](../packages/coding-agent/src/session/blob-store.ts)\n\n## On-Disk Layout\n\nDefault managed session file location:\n\n```text\n~/.gjc/agent/sessions/v2-<52-char-base32-sha256>/_.jsonl\n```\n\nThe `v2-…` component is a fixed-width SHA-256/base32 digest of the native canonical workspace identity (identity version 1); it is **not** a reversible or injective user-facing encoding. The binding file `.gjc-managed-session-scope.v2.json` records the canonical identity and digest. Existing bindings must be regular, canonically encoded files that agree with the resolved identity; a mismatch or unsafe path fails closed.\n\nIdentity is platform-specific:\n\n- POSIX paths and supported local aliases that resolve to the same native directory identity share the same v2 scope.\n- On Windows, equivalent supported local path spellings (including drive-letter/case aliases) resolve through the native identity API before the scope is derived.\n- UNC/network workspaces are unsupported and return a `network_unsupported` resolution result; no SMB share is needed or assumed by this design.\n\nThe default managed writer creates new data only in v2 scopes. It never writes new legacy-layout data. `--session-dir` is an explicit storage/lookup override and is not a request to derive the default managed scope.\n\n### Legacy migration and retention\n\nLegacy encoded directories are discovered only after validating each candidate's header and workspace identity. With `session.directoryMigration: \"copy-retain\"` (the default), an eligible legacy session is copied into the v2 scope without replacing an existing destination; the legacy source is retained. Set `session.directoryMigration: \"disabled\"` to leave legacy candidates unmigrated. Migration is lazy and guarded by a managed lock, binding checks, no-follow/owner-only path checks, and source identity validation; conflicts, unsafe artifacts, or changed sources fail rather than guessing.\n\nMigration does not automatically clean up legacy files, copied files, locks, artifacts, or abandoned data. A migration tombstone records a completed/retired source so repeated scans do not reinterpret it as a new migration request; it is not evidence that the old data was deleted. Artifact copying is bounded and rejects symlinks, hard links, excessive depth, file count, or size.\n\n### Security boundary\n\nManaged storage enforces owner-only directory/file security and refuses unsafe symlinks or malformed bindings on the paths it verifies. This is a local storage-integrity boundary, not authentication, authorization, encryption, or a guarantee against a hostile concurrent local actor/race outside the verified operations. Callers must still protect the agent directory and session contents.\n\nOn Linux filesystems where the exact POSIX ACL xattr operation returns `ENOTSUP`/`EOPNOTSUPP`, GJC treats that result only as proof that the filesystem cannot store that ACL attribute. The ACL gate still requires the same opened object to pass effective-owner, exact `0700` directory or `0600` file mode, safe-type, no-follow traversal, and identity/replacement checks. Permission denial, I/O errors, present or malformed ACL data, and unknown results remain failures. Managed descriptors use close-on-exec and are not delegated as authority to subprocesses. This compatibility rule does not change explicit `--session-dir`, macOS ACL, or Windows DACL policy.\n\nBlob store location:\n\n```text\n~/.gjc/agent/blobs/\n```\n\nTerminal breadcrumb files are written under:\n\n```text\n~/.gjc/agent/terminal-sessions/\n```\n\nBreadcrumb content is two lines: original cwd, then session file path. `continueRecent()` prefers this terminal-scoped pointer before scanning most-recent mtime.\n\n## File Format\n\nSession files are JSONL: one JSON object per line.\n\n- Line 1 is always the session header (`type: \"session\"`).\n- Remaining lines are `SessionEntry` values or v4/v5 append-only patch records. `header_patch` records update header metadata and `entry_patch` records replace a message payload when replay metadata is sanitized.\n- Entries and patch records are append-only at runtime; branch navigation moves a pointer (`leafId`) rather than mutating existing entries.\n\n### Header (`SessionHeader`)\n\n```json\n{\n \"type\": \"session\",\n \"version\": 5,\n \"id\": \"1f9d2a6b9c0d1234\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\",\n \"cwd\": \"/work/pi\",\n \"title\": \"optional session title\",\n \"titleSource\": \"auto\",\n \"parentSession\": \"optional lineage marker\"\n}\n```\n\nNotes:\n\n- `version` is optional in v1 files; absence means v1.\n- `parentSession` is an opaque lineage string. Current code writes either a session id or a session path depending on flow (`fork`, `forkFrom`, `createBranchedSession`, or explicit `newSession({ parentSession })`). Treat as metadata, not a typed foreign key.\n\n### Entry Base (`SessionEntryBase`)\n\nAll non-header entries include:\n\n```json\n{\n \"type\": \"...\",\n \"id\": \"8-char-id\",\n \"parentId\": \"previous-or-branch-parent\",\n \"timestamp\": \"2026-02-16T10:20:30.000Z\"\n}\n```\n\n`parentId` can be `null` for a root entry (first append, or after `resetLeaf()`).\n\n## Entry Taxonomy\n\n`SessionEntry` is the union of:\n\n- `message`\n- `thinking_level_change`\n- `service_tier_change`\n- `compaction`\n- `branch_summary`\n- `custom`\n- `custom_message`\n- `label`\n- `ttsr_injection`\n- `session_init`\n- `mode_change`\n- `mcp_tool_selection`\n- `discovered_builtin_tool_selection`\n\n### `message`\n\nStores an `AgentMessage` directly.\n\n```json\n{\n \"type\": \"message\",\n \"id\": \"a1b2c3d4\",\n \"parentId\": null,\n \"timestamp\": \"2026-02-16T10:21:00.000Z\",\n \"message\": {\n \"role\": \"assistant\",\n \"provider\": \"anthropic\",\n \"model\": \"anthropic-model-sonnet-4-5\",\n \"content\": [{ \"type\": \"text\", \"text\": \"Done.\" }],\n \"usage\": {\n \"input\": 100,\n \"output\": 20,\n \"cacheRead\": 0,\n \"cacheWrite\": 0,\n \"cost\": {\n \"input\": 0,\n \"output\": 0,\n \"cacheRead\": 0,\n \"cacheWrite\": 0,\n \"total\": 0\n }\n },\n \"timestamp\": 1760000000000\n }\n}\n```\n\n### `model_change`\n\n```json\n{\n \"type\": \"model_change\",\n \"id\": \"b1c2d3e4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:21:30.000Z\",\n \"model\": \"openai/gpt-4o\",\n \"role\": \"default\"\n}\n```\n\n`role` is optional; missing is treated as `default` in context reconstruction.\n\n### `service_tier_change`\n\n```json\n{\n \"type\": \"service_tier_change\",\n \"id\": \"c1d2e3f4\",\n \"parentId\": \"b1c2d3e4\",\n \"timestamp\": \"2026-02-16T10:21:45.000Z\",\n \"serviceTier\": \"flex\"\n}\n```\n\n`serviceTier` can also be `null`.\n\n### `thinking_level_change`\n\n```json\n{\n \"type\": \"thinking_level_change\",\n \"id\": \"c1d2e3f4\",\n \"parentId\": \"b1c2d3e4\",\n \"timestamp\": \"2026-02-16T10:22:00.000Z\",\n \"thinkingLevel\": \"high\"\n}\n```\n\n### `compaction`\n\n```json\n{\n \"type\": \"compaction\",\n \"id\": \"d1e2f3a4\",\n \"parentId\": \"c1d2e3f4\",\n \"timestamp\": \"2026-02-16T10:23:00.000Z\",\n \"summary\": \"Conversation summary\",\n \"shortSummary\": \"Short recap\",\n \"firstKeptEntryId\": \"a1b2c3d4\",\n \"tokensBefore\": 42000,\n \"details\": { \"readFiles\": [\"src/a.ts\"] },\n \"preserveData\": { \"hookState\": true },\n \"fromExtension\": false\n}\n```\n\n### `branch_summary`\n\n```json\n{\n \"type\": \"branch_summary\",\n \"id\": \"e1f2a3b4\",\n \"parentId\": \"a1b2c3d4\",\n \"timestamp\": \"2026-02-16T10:24:00.000Z\",\n \"fromId\": \"a1b2c3d4\",\n \"summary\": \"Summary of abandoned path\",\n \"details\": { \"note\": \"optional\" },\n \"fromExtension\": true\n}\n```\n\nIf branching from root (`branchFromId === null`), `fromId` is the literal string `\"root\"`.\n\n### `custom`\n\nExtension state persistence; ignored by `buildSessionContext`.\n\n```json\n{\n \"type\": \"custom\",\n \"id\": \"f1a2b3c4\",\n \"parentId\": \"e1f2a3b4\",\n \"timestamp\": \"2026-02-16T10:25:00.000Z\",\n \"customType\": \"my-extension\",\n \"data\": { \"state\": 1 }\n}\n```\n\n### `custom_message`\n\nExtension-provided message that does participate in LLM context. `content` can be a string or text/image content blocks, and `attribution` records whether the user or agent initiated it.\n\n```json\n{\n \"type\": \"custom_message\",\n \"id\": \"a2b3c4d5\",\n \"parentId\": \"f1a2b3c4\",\n \"timestamp\": \"2026-02-16T10:26:00.000Z\",\n \"customType\": \"my-extension\",\n \"content\": \"Injected context\",\n \"display\": true,\n \"details\": { \"debug\": false },\n \"attribution\": \"agent\"\n}\n```\n\n### `label`\n\n```json\n{\n \"type\": \"label\",\n \"id\": \"b2c3d4e5\",\n \"parentId\": \"a2b3c4d5\",\n \"timestamp\": \"2026-02-16T10:27:00.000Z\",\n \"targetId\": \"a1b2c3d4\",\n \"label\": \"checkpoint\"\n}\n```\n\n`label: undefined` clears a label for `targetId`.\n\n### `ttsr_injection`\n\n```json\n{\n \"type\": \"ttsr_injection\",\n \"id\": \"c2d3e4f5\",\n \"parentId\": \"b2c3d4e5\",\n \"timestamp\": \"2026-02-16T10:28:00.000Z\",\n \"injectedRules\": [\"ruleA\", \"ruleB\"]\n}\n```\n\n### `mcp_tool_selection`\n\n```json\n{\n \"type\": \"mcp_tool_selection\",\n \"id\": \"d2e3f4a5\",\n \"parentId\": \"c2d3e4f5\",\n \"timestamp\": \"2026-02-16T10:28:30.000Z\",\n \"selectedToolNames\": [\"server.tool\"]\n}\n```\n\n### `discovered_builtin_tool_selection`\n\n```json\n{\n \"type\": \"discovered_builtin_tool_selection\",\n \"id\": \"e2f3g4h5\",\n \"parentId\": \"d2e3f4a5\",\n \"timestamp\": \"2026-02-16T10:28:31.000Z\",\n \"selectedToolNames\": [\"search_tool_bm25\"],\n \"mutationCorrelationId\": \"4c2b9c60-20d7-4a18-8d2a-8edc1f892b89\"\n}\n```\n\n`selectedToolNames` is the explicit discovered built-in selection. `mutationCorrelationId` is optional and correlates adjacent MCP and discovered built-in selection records from one mutation.\n\n### `session_init`\n\n```json\n{\n \"type\": \"session_init\",\n \"id\": \"d2e3f4a5\",\n \"parentId\": \"c2d3e4f5\",\n \"timestamp\": \"2026-02-16T10:29:00.000Z\",\n \"systemPrompt\": \"...\",\n \"task\": \"...\",\n \"tools\": [\"read\", \"edit\"],\n \"outputSchema\": { \"type\": \"object\" }\n}\n```\n\n### `mode_change`\n\n```json\n{\n \"type\": \"mode_change\",\n \"id\": \"e2f3a4b5\",\n \"parentId\": \"d2e3f4a5\",\n \"timestamp\": \"2026-02-16T10:30:00.000Z\",\n \"mode\": \"plan\",\n \"data\": { \"planFile\": \"/tmp/plan.md\" }\n}\n```\n\n## Versioning and Migration\n\nCurrent session version: `5`.\n\n### v1 -> v2\n\nApplied when header `version` is missing or `< 2`:\n\n- Adds `id` and `parentId` to each non-header entry.\n- Reconstructs a linear parent chain using file order.\n- Migrates compaction field `firstKeptEntryIndex` -> `firstKeptEntryId` when present.\n- Sets header `version = 2`.\n\n### v2 -> v3\n\nApplied when header `version < 3`:\n\n- For `message` entries: rewrites legacy `message.role === \"hookMessage\"` to `\"custom\"`.\n- Sets header `version = 3`.\n\n### v3 -> v4\n\nApplied when header `version < 4`:\n\n- Sets header `version = 4`.\n- Introduces append-only `header_patch` and `entry_patch` records.\n\n### v4 -> v5\n\nApplied when header `version < 5`:\n\n- Sets header `version = 5`.\n- Separates MCP (`mcp_tool_selection`) and discovered built-in (`discovered_builtin_tool_selection`) selection authority. The legacy v4 combined built-in field remains readable.\n- Patch records replay for v4 and v5 transcripts. Headers with a version greater than 5 are rejected before replay.\n\n### Migration Trigger and Persistence\n\n- v1-v4 transcripts remain readable without mutation during read-only inspection and strict resume selection. Patch records replay for v4 and v5 transcripts; headers with a version greater than 5 are rejected before replay.\n- Mutable loads migrate v1-v4 entries in memory but do not rewrite on read. Migration and the complete v5 rewrite are deferred until the first authorized persistence.\n- v5 sessions load without a migration rewrite. Once v5 data exists, do not roll back to a v4 writer: v4 writers cannot preserve v5 selection authority.\n\n### Discovery selection authority\n\nMCP and discovered built-in authority are independent. Constructor `toolNames` establishes authority only for the domain it names; currently essential built-ins remain baseline policy and never become discovered-built-in authority. A list containing only non-essential built-ins does not suppress configured or exact-config MCP defaults, and a list containing only MCP tools does not suppress built-in baselines. An explicit empty list clears both applicable domains. Explicit new-session names and empty clears are persisted as separate domain entries; omitted selections, essential baselines, and configured/exact baselines are not authoritative and are not persisted. Resume reconstructs state without appending authority entries.\n\nA combined activation appends an MCP entry first and a discovered-built-in entry second. Both entries carry the same optional `mutationCorrelationId`; older entries without this field remain valid.\n## Load and Compatibility Behavior\n\n`loadEntriesFromFile(path)` behavior:\n\n- Missing file (`ENOENT`) -> returns `[]`.\n- Non-parseable lines are handled by lenient JSONL parser (`parseJsonlLenient`).\n- If first parsed entry is not a valid session header (`type !== \"session\"` or missing string `id`) -> returns `[]`.\n\n`SessionManager.setSessionFile()` behavior:\n\n- `[]` from loader is treated as empty/nonexistent session and replaced with a new initialized session file at that path.\n- Valid files are loaded, migrated if needed, blob refs resolved, then indexed.\n\n## Tree and Leaf Semantics\n\nThe underlying model is append-only tree + mutable leaf pointer:\n\n- Every append method creates exactly one new entry whose `parentId` is current `leafId`.\n- The new entry becomes the new `leafId`.\n- `branch(entryId)` moves only `leafId`; existing entries remain unchanged.\n- `resetLeaf()` sets `leafId = null`; next append creates a new root entry (`parentId: null`).\n- `branchWithSummary()` sets leaf to branch target and appends a `branch_summary` entry.\n\n`getEntries()` returns all non-header entries in insertion order. Existing entries are not deleted in normal operation; rewrites preserve logical history while updating representation (migrations, move, targeted rewrite helpers).\n\n## Context Reconstruction (`buildSessionContext`)\n\n`buildSessionContext(entries, leafId, byId?)` resolves what is sent to the model.\n\nAlgorithm:\n\n1. Determine leaf:\n - `leafId === null` -> return empty context.\n - explicit `leafId` -> use that entry if found.\n - otherwise fallback to last entry.\n2. Walk `parentId` chain from leaf to root and reverse to root->leaf path.\n3. Derive runtime state across path:\n - `thinkingLevel` from latest `thinking_level_change` (default `\"off\"`)\n - `serviceTier` from latest `service_tier_change`\n - model map from `model_change` entries (`role ?? \"default\"`)\n - fallback `models.default` from assistant message provider/model if no explicit model change\n - deduplicated `injectedTtsrRules` from all `ttsr_injection` entries\n - selected MCP discovery tools from latest `mcp_tool_selection`\n - mode/modeData from latest `mode_change` (default mode `\"none\"`)\n4. Build message list:\n - `message` entries pass through\n - `custom_message` entries become `custom` AgentMessages via `createCustomMessage`\n - `branch_summary` entries become `branchSummary` AgentMessages via `createBranchSummaryMessage`\n - if a `compaction` exists on path:\n - emit compaction summary first (`createCompactionSummaryMessage`)\n - emit path entries starting at `firstKeptEntryId` up to the compaction boundary\n - emit entries after the compaction boundary\n\n`custom`, `session_init`, `service_tier_change`, `mcp_tool_selection`, and `ttsr_injection` entries do not inject model context directly.\n\n## Persistence Guarantees and Failure Model\n\n### Persist vs in-memory\n\n- `SessionManager.create/open/continueRecent/forkFrom` -> persistent mode (`persist = true`).\n- `SessionManager.inMemory` -> non-persistent mode (`persist = false`) with `MemorySessionStorage`.\n\n### Write pipeline\n\nWrites are serialized through an internal promise chain (`#persistChain`) and `NdjsonFileWriter`.\n\n- `append*` updates in-memory state immediately.\n- Persistence is deferred until at least one assistant message exists.\n - Before first assistant: entries are retained in memory; no file append occurs.\n - When first assistant exists: full in-memory session is flushed to file.\n - Afterwards: new entries append incrementally.\n\nRationale in code: avoid persisting sessions that never produced an assistant response.\n\n### Durability operations\n\n- `flush()` flushes writer and calls `fsync()`.\n- Atomic full rewrites (`#rewriteFile`) write to temp file, flush+fsync, close, then rename over target.\n- Used for migrations, `setSessionName`, `rewriteEntries`, move operations, and tool-call arg rewrites.\n\n### Error behavior\n\n- Persistence errors are latched (`#persistError`) and rethrown on subsequent operations.\n- First error is logged once with session file context.\n- Writer close is best-effort but propagates the first meaningful error.\n\n## Data Size Controls and Blob Externalization\n\nBefore persisting entries:\n\n- Large strings are truncated to `MAX_PERSIST_CHARS` (500,000 chars) with notice:\n - `\"[Session persistence truncated large content]\"`\n- Transient fields `partialJson` and `jsonlEvents` are removed.\n- If object has both `content` and `lineCount`, line count is recomputed after truncation.\n- Image blocks in `content` arrays with base64 length >= 1024 are externalized to blob refs:\n - stored as `blob:sha256:`\n - raw bytes written to blob store (`BlobStore.put`)\n\nOn load, blob refs are resolved back to base64 for message/custom_message image blocks.\n\n## Storage Abstractions\n\n`SessionStorage` interface provides all filesystem operations used by `SessionManager`:\n\n- sync: `ensureDirSync`, `existsSync`, `writeTextSync`, `statSync`, `listFilesSync`\n- async: `exists`, `readText`, `readTextPrefix`, `writeText`, `rename`, `unlink`, `openWriter`\n\nImplementations:\n\n- `FileSessionStorage`: real filesystem (Bun + node fs)\n- `MemorySessionStorage`: map-backed in-memory implementation for tests/non-persistent sessions\n\n`SessionStorageWriter` exposes `writeLine`, `flush`, `fsync`, `close`, `getError`.\n\n## Session Discovery Utilities\n\nDefined in `session-manager.ts`:\n\n- `getRecentSessions(sessionDir, limit)` -> lightweight metadata for UI/session picker\n- `findMostRecentSession(sessionDir)` -> newest by mtime\n- `list(cwd, sessionDir?)` -> sessions in one project scope\n- `listAll()` -> sessions across all project scopes under `~/.gjc/agent/sessions`\n\nMetadata extraction reads only a prefix (`readTextPrefix(..., 4096)`) where possible.\n\n## Related but Distinct: Prompt History Storage\n\n`HistoryStorage` (`history-storage.ts`) is a separate SQLite subsystem for prompt recall/search, not session replay.\n\n- DB: `~/.gjc/agent/history.db`\n- Table: `history(id, prompt, created_at, cwd)`\n- FTS5 index: `history_fts` with trigger-maintained sync\n- Deduplicates consecutive identical prompts using in-memory last-prompt cache\n- Async insertion (`setImmediate`) so prompt capture does not block turn execution\n\nUse session files for conversation graph/state replay; use `HistoryStorage` for prompt history UX.\n", "slack-onboarding.md": "# Slack notification onboarding\n\nThis is the managed Slack Socket Mode notification adapter. It is an SDK client:\nlocal GJC sessions continue to own loopback SDK endpoints, and Slack provides a\nper-session message thread for notifications and replies.\n\n## Prerequisites\n\nCreate a Slack app in the target workspace, enable Socket Mode, and create an\napp-level token with the Socket Mode connection scope. Install the app in the\nworkspace and invite it to the selected channel. Configure only the scopes and\nevent subscriptions the adapter needs:\n\n- `chat:write` to post session roots, replies, and closure markers\n- `channels:history` for a public channel, or the corresponding history scope\n for the channel type in use\n- the message event subscription for the selected channel type\n- Socket Mode enabled for Events API delivery\n\nKeep the selected channel private to people authorized to see local session\nmetadata. Do not add broad workspace scopes or use an app token for ordinary Web\nAPI calls.\n\n## Configure the adapter\n\n`gjc notify setup slack` is non-interactive. It requires these flags:\n\n- `--slack-bot-token`\n- `--slack-app-token`\n- `--slack-workspace-id`\n- `--slack-channel-id`\n- `--slack-authorized-user-id` for the single Slack user authorized to submit replies and `/sdk` commands\n\nWithout `--slack-authorized-user-id`, the adapter remains outbound-only: every inbound envelope is acknowledged but denied before it can create a durable claim or reach an SDK endpoint. The user ID is an identifier, not a secret. It also accepts `--redact`. Provide secret values from an approved local secret mechanism, not shell history, committed configuration, tickets, screenshots, or chat. Setup writes:\n\n- `notifications.enabled = true`\n- `notifications.slack.enabled = true` (durable desired intent)\n- `notifications.slack.botToken`\n- `notifications.slack.appToken`\n- `notifications.slack.workspaceId`\n- `notifications.slack.channelId`\n- `notifications.slack.authorizedUserId` when configured\n- `notifications.redact = true` when requested\n\n`gjc notify status` reports Slack completeness, repair/quarantine state, desired intent, effective enablement, destination identifiers, and masked token values. It is status output, not a credential recovery mechanism. A successful durable save is not rolled back when later daemon activation fails; the command reports the saved-but-runtime-degraded outcome and exits nonzero. In `/settings`, bot/app secret edits are explicit `keep`, `replace`, or `remove`; removing either required token turns Slack desired intent off without changing Telegram, Discord, or the global master.\n\n## Socket Mode, threads, and resume\n\nThe daemon validates the configured workspace, channel, and paired user before durably claiming an inbound effect or sending its Socket Mode acknowledgement. The durable claim records the paired actor identity, replay identity, protected-effect reference, and captured endpoint generation; it never records Socket Mode cursors, endpoint tokens, or message bodies. Rejected, bot-authored, unauthorized, and already-claimed envelopes are acknowledged without an SDK endpoint call.\n\nAcknowledgement latency is therefore bounded by local durable-claim work rather\nthan SDK availability or command execution. After the ACK, the worker dispatches\nthe claimed effect asynchronously; a restart can replay the claim, and a retry\ncannot create a second injection. Do not treat an ACK as confirmation that the SDK\noperation completed.\n\nEach session starts with one root message. Root creation uses a caller-generated\nclient message ID and reconciliation lookup, preventing a duplicate root after\nan uncertain post. When a session closes, the daemon posts a closure marker. A\nresume starts a new immutable root, so replies to the old root are rejected and\ncannot steer the resumed session.\n\nEvents, retried deliveries, event contexts, and interaction/message identifiers\nare deduplicated in the durable claim before a reply is injected into the captured\ncurrent endpoint generation. After a Socket Mode reconnect, Slack may redeliver an\nenvelope; the new delivery is acknowledged after its claim is recognized and\ncannot cause a second injection.\n\n## Operational safety\n\nTreat rate limits, permission failures, and Socket Mode disconnects as transport\nfailures. Let the managed daemon reconnect or reconcile; do not run a competing\nSocket Mode consumer against the same app/state, manually modify conversation\nstate, persist delivery cursors, expose loopback endpoints, or use Slack as a\ngeneral remote shell.\n\nThe adapter only sends notifications and routes SDK replies. It does not support\nprovider registration, retaining endpoint credentials, or arbitrary remote\ncontrol.\n\n## Verification boundary\n\nAcceptance coverage uses an injectable fake Slack provider plus a production\nSession SDK host boundary proof. It covers durable-claim-before-acknowledgement\nfor accepted, rejected, duplicate, and reconnect-redelivered envelopes; root-post\nreconciliation; event/retry/context/interaction dedupe; generation and restart\nisolation; rate-limit/permission/disconnect failures; and the prohibition on\npersisted Socket Mode cursors. No live Slack credentials or workspace is required.\n", "standalone-mcp.md": "# Standalone MCP configuration\n\n`gjc mcp add` writes only the definition supplied on that invocation to GJC's own MCP config (`~/.gjc/agent/mcp.json` by default, or `./.gjc/mcp.json` with `--project`). `gjc mcp list` and `gjc mcp remove` print redacted definitions. These commands are storage-only: normal standalone startup does not consume registered definitions.\n\n## Use an explicit config\n\nA caller can opt one top-level standalone session into one trusted config file:\n\n```bash\ngjc --mcp-config /absolute/path/to/mcp.json\n```\n\nThe path must be absolute and identify a regular file directly; symbolic links and other indirection are rejected. GJC reads the file through one open handle and rejects it if the path, file identity, size, or modification metadata changes during the read. It exposes only that file's MCP tools and owns the server processes for that session. It does not load server prompts, resources, instructions, sampling, or other config files. Expected read, parse, validation, and connection failures emit one sanitized warning and continue. Unexpected errors and final-catalog tool-name collisions clean up and abort startup.\n\nThere is no MCP config discovery or merge, reload while the session runs, subagent inheritance, or default behavior change. To use a stored registration, pass that exact stored config path with `--mcp-config`.\n\n## Supported integrations\n\n| Need | Use | Notes |\n| --- | --- | --- |\n| User trusts one MCP config for one standalone session | `gjc --mcp-config /absolute/path/to/mcp.json` | Exact-file, top-level, tools-only opt-in; GJC owns cleanup. |\n| External bot or multi-session controller | [Coordinator MCP](./hermes-mcp-bridge.md) | Coordinator MCP exposes GJC lifecycle and coordination tools. |\n| External session control | [SDK machine interface](./sdk.md) | The SDK WebSocket protocol is the only external control interface. |\n| Editor/ACP client owns MCP servers | ACP via `gjc --mode acp` or `gjc acp` | ACP remains a stdio editor protocol. |\n| Codex / Claude Code delegation plugin | [Canonical gajae-code plugin](./hermes-mcp-bridge.md) | Installs Coordinator MCP plus GJC delegation commands. |\n\n## Boundary\n\nStandalone GJC does not inherit arbitrary MCP server configurations from Claude Code, Codex, OpenCode, or other tools. MCP servers often carry credentials, filesystem reach, browser state, approval semantics, and lifecycle that belong to the configuring host.\n\n`--mode rpc`, `--mode rpc-ui`, and `--mode bridge` have been removed. Do not use the former RPC host-tool protocol to connect an MCP server; use the [SDK machine interface](./sdk.md) for supported external session control.\n\n## Related docs\n\n- [SDK machine interfaces](./sdk.md)\n- [Coordinator MCP bridge](./hermes-mcp-bridge.md)\n- [External control surface readiness](./external-control-readiness.md)", - "telegram-onboarding.md": "# Telegram notification onboarding\n\nThis guide documents the bundled Telegram notification setup path from Gajae-Code\nsource. In an interactive GJC session, use `/settings` → **Notifications** as the\nrecommended path; `gjc notify` remains the authoritative headless and automation\nfallback. It is for the managed reference client, not a separate remote-control\nproduct.\n\n## What you are setting up\n\nGajae-Code notifications are a loopback WebSocket SDK plus a managed Telegram\nreference daemon:\n\n- each GJC session publishes a local notification endpoint under\n `.gjc/state/sdk/.json`;\n- the managed Telegram daemon scans those endpoints, connects to them, and sends\n action-needed events to the configured Telegram chat;\n- replies and inline button taps route back to the exact session/action through\n the same notification protocol. When the configured chat supports Telegram\n forum topics, each session is routed through its own topic.\n\nThe setup command stores global notification settings in your GJC agent config\nand later sessions auto-connect when notifications are enabled.\n\n## 1. Create a Telegram bot with BotFather\n\nUse Telegram's official BotFather flow to create a bot and copy its HTTP API\ntoken:\n\n- Official BotFather documentation: \n- General Telegram Bot API documentation: \n\nIn Telegram, open `@BotFather`, run `/newbot`, choose a display name and a unique\nusername ending in `bot`, then copy the token BotFather returns. Treat the token\nlike a password: do not paste it into logs, screenshots, issues, or shell history\nthat other people can read.\n\n## 2. Configure from `/settings` (recommended)\n\nIn an eligible running GJC session, open `/settings` and select the\n**Notifications** tab. It provides the interactive Telegram setup/reconfigure\nflow and the operational controls in one place:\n\n- Enable globally with stored credentials or disable globally;\n- turn notifications on or off for the current session only;\n- refresh or probe health, send a test notification, recover dead-owner\n artifacts, and reconnect the Telegram runtime;\n- remove Telegram credentials without removing configured Discord or Slack\n adapters.\n\nTelegram token entry is a masked setup field. After entry, the token is never\nprefilled, rendered, or shown by the tab; status and health use a masked value.\nThe tab also guides the BotFather Threaded Mode check and private-chat pairing.\n\n### CLI setup fallback\n\n`gjc notify setup` retains the same setup workflow for terminal-driven setup and\nautomation:\n\n```sh\ngjc notify setup\n```\n\nCurrent implementation path: `packages/coding-agent/src/cli/notify-cli.ts`.\n\nThe wizard does this:\n\n1. prompts for `Telegram BotFather token:`;\n2. validates the token with Telegram `getMe`;\n3. verifies private-chat Threaded Mode capability via `getMe.has_topics_enabled`\n and, when it is off in an interactive run, prints @BotFather guidance and\n lets you retry or continue unverified;\n4. asks you to message the bot from a private Telegram chat;\n5. polls Telegram `getUpdates` until it sees a private chat message;\n6. writes the paired chat id and enables notifications.\n\nThe setup pairing flow is private-chat only. If setup sees a `group`,\n`supergroup`, or `channel`, it rejects that chat and keeps waiting for a private\nDM. This is intentional for safe local discovery: group chats must not receive\nsession names, action ids, or pending status by accident.\n\nTelegram private-chat topics: the managed daemon's per-session delivery uses\nTelegram forum topics (`createForumTopic` + `message_thread_id`). Telegram now\nsupports forum topics in **private chats** when the bot owner enables **Threaded\nMode** for the bot in @BotFather. GJC cannot enable Threaded Mode through the Bot\nAPI; setup only detects the capability (`getMe.has_topics_enabled`) and guides the\nmanual BotFather toggle. A forum-enabled supergroup is no longer required.\n\nNote: enabling topics in private chats may require an additional Telegram Stars\npurchase fee, per Telegram's Terms of Service for Bot Developers.\n\nIf BotFather's **Bot Settings** menu does not show **Threads Settings** or\n**Threaded Mode**, do not treat that as a setup blocker. Telegram exposes this\ncapability unevenly across clients/accounts/bot states, and GJC cannot force the\nmenu to appear through the Bot API. The safe fallback is to continue setup with a\nprivate DM pairing: choose `skip` in the interactive prompt (or use\n`--token --chat-id ` for non-interactive setup). GJC will save\n`threaded=unverified`/`threaded=unknown`, try topics at runtime when possible,\nand otherwise deliver flat to the paired private chat with outbound notifications\nand inline ask buttons only plus the one-time nudge shown below.\n\nSetup verification is capability verification, not a delivery guarantee: even when\nsetup reports `threaded=verified`, the first runtime `createForumTopic` for the\npaired chat can still fail if Telegram refuses it. When per-session topics are\nunavailable, the daemon does **not** drop notifications — it routes them to the\nnormal (flat) paired chat and posts a one-time nudge: `Flat Telegram private chat\nsupports outbound notifications and inline ask buttons only. Enable Threaded Mode\nin @BotFather > Bot Settings > Threads Settings for free-text replies and session\ncommands.` Because pairing is private-only, flat delivery lands in your own\nprivate DM with the bot.\n\nThe final setup line reports a `threaded=` status:\n\n- `threaded=verified`: the bot has Threaded Mode capability (`has_topics_enabled`\n was true during setup);\n- `threaded=unverified`: Threaded Mode was off and you skipped, or setup ran\n non-interactively; setup is saved, topics are attempted when available, and\n runtime delivery falls back to the paired flat private chat with outbound\n notifications and inline ask buttons only when Telegram refuses topic creation;\n- `threaded=unknown`: the Telegram response did not include `has_topics_enabled`,\n so capability could not be verified.\n\nAfter setup succeeds, it prints a masked token and the paired chat id:\n\n```text\nNotifications enabled. botToken=1234…(len N) chatId=123456789 threaded=verified\n```\n\nThe raw token is never printed by GJC status/setup output after it is stored.\n\n## 3. Non-interactive setup and CLI operations\n\nFor headless provisioning, scripts, and automation, the authoritative commands\nremain `gjc notify setup`, `gjc notify status`, `gjc notify health`, `gjc notify\ntest`, and `gjc notify recovery`. The `/settings` tab does not replace these CLI\nsubcommands.\n\nFor scripts or CI-style local provisioning, pass the bot token and known private\nchat id explicitly. Non-interactive runs cannot prompt for the BotFather toggle,\nso if Threaded Mode is off (or the capability is unknown) setup is still saved\nwith a warning and a `threaded=unverified`/`threaded=unknown` status:\n\n```sh\ngjc notify setup --token --chat-id \n```\n\nOptional redaction can be enabled during setup:\n\n```sh\ngjc notify setup --token --chat-id --redact\n```\n\n`--redact` sets `notifications.redact = true`. Under redaction, idle summaries\nand streamed content are suppressed before remote delivery, but ask questions and\noptions remain readable because they must be answerable remotely.\n\n## 4. Check status without leaking secrets\n\n```sh\ngjc notify status\n```\n\nThe status command reports the global master plus each provider's independent\nconfiguration completeness, repair/quarantine state, durable desired-intent\nsource, and effective enablement. Stored tokens are masked with the shared\n`first 4 chars + … + length` helper. Destination identifiers such as Telegram\nchat IDs remain visible and may be sensitive, so redact them before pasting a\nstatus report into a public support thread. Runtime readiness and actual\ndelivery outcomes remain separate; use `gjc notify health --provider telegram`\nand `gjc notify test --provider telegram` for those checks.\n\n## 5. Global configuration, adapters, and precedence\n\nTelegram credentials and all `notifications.*` values are **global-only**. GJC\nreads them from the user/global agent config with schema defaults; notification\nkeys from project config files are ignored, and runtime notification overrides\nare rejected. A project cannot supply, shadow, or disable an outbound\nnotification identity.\n\n`gjc notify setup` writes these global Telegram settings through the GJC Settings\nlayer:\n\n- `notifications.enabled = true`\n- `notifications.telegram.enabled = true` (durable desired intent)\n- `notifications.telegram.botToken = `\n- `notifications.telegram.chatId = `\n- `notifications.redact = true` only when `--redact` was passed\n- `notifications.telegram.streaming.enabled = true` by default; set it to `false` to disable durable live Telegram assistant-output updates globally. `GJC_NOTIFICATIONS_STREAM=1` forces process-local streaming, while `0`, `off`, or `false` forces it off.\n\nProvider completeness, malformed-state quarantine, desired intent, effective enablement, runtime readiness, and delivery outcome are separate status dimensions. Telegram is complete when its bot token and private-chat id are valid; it is effective only when it is complete, not quarantined, desired on, and the global master is on. Provider-local malformed values are quarantined without erasing safe sibling values or secrets. Removing Telegram is adapter-local: it removes only Telegram credentials and sets Telegram desired intent off without changing `notifications.enabled` or any Discord/Slack state.\n\n\nThree lifecycle gates keep SDK hosting, setup, and managed delivery separate:\n\n1. An eligible host receives the dormant notification control surface. `GJC_NOTIFY=off`,\n `0`, or `false` is a hard process opt-out; unsupported hosts and\n helper/subagent sessions are also ineligible.\n2. Every eligible top-level session hosts its local SDK endpoint by default,\n independently of notification configuration. `GJC_SDK_DISABLE=1` opts out of\n SDK hosting for that session.\n3. A managed Telegram daemon is ensured only for a complete global Telegram\n configuration with managed delivery enabled. Discord-only, Slack-only, and\n environment-only sessions do not start a Telegram daemon.\n\nEnvironment/session precedence for managed delivery is implemented in\n`packages/coding-agent/src/sdk/bus/config.ts`:\n\nFor a GJC-spawned child, `notifications.sessionScope=primary` suppresses managed\nnotification delivery to avoid duplicate topics; `all` permits it.\n`GJC_NOTIFICATIONS=1` or `GJC_NOTIFICATIONS_TOKEN` explicitly opts that child in,\nbut never overrides a hard opt-out or a helper/subagent exclusion.\n\nManaged-delivery precedence is highest first; it does not change independently\nhosted SDK endpoints:\n\n1. `GJC_NOTIFY=off`, `0`, or `false` prevents the notification control surface\n for that process.\n2. `GJC_NOTIFICATIONS=0` suppresses automatic generic current-session admission; explicit `/notify on` may override that suppression only for the current session.\n3. Local `/notify off` disables managed delivery only for the current session.\n4. `GJC_NOTIFICATIONS=1` or `GJC_NOTIFICATIONS_TOKEN` enables the legacy\n explicit managed-delivery path.\n5. A complete global configuration enables managed delivery automatically.\n6. Otherwise managed delivery stays off; the SDK endpoint remains hosted unless\n `GJC_SDK_DISABLE=1` is set.\n\n## 6. Start or reuse sessions\n\nAfter setup, start GJC normally:\n\n```sh\ngjc --tmux\n```\n\nor use any other supported GJC launch mode. Every eligible top-level session\nwrites its SDK endpoint unless `GJC_SDK_DISABLE=1`; when managed Telegram\ndelivery is configured and enabled, it also ensures the Telegram daemon is running.\n\nThe managed daemon is a singleton per bot token/chat pair. Telegram allows only\none active `getUpdates` long-poll owner for a bot token, so GJC keeps a local\ndaemon lock/state file and makes later sessions attach to the fresh owner instead\nof starting a second poller. This avoids Telegram `409 Conflict` failures.\n\n### Same-token and foreign-owner safety\n\nSetup and reconfigure never compete with a live same-token daemon. When a live\nowner already has the stored paired chat, GJC reuses it after non-polling\nvalidation. If that owner has no stored chat or the chat changes, provide a\nvalidated private chat id; GJC performs zero `getUpdates` discovery polls. For a\nforeign or unknown owner, setup does not poll, kill, reload, or take over the\nowner; the default is to cancel before writing configuration.\n\nFor a Telegram-only setup, an explicit **Save inactive for later** choice may\nstore the credentials with notifications disabled. That choice is unavailable\nwhen a complete Discord or Slack adapter is active, because globally disabling\nnotifications would affect that adapter. A post-save identity race similarly\nstops the current session before reporting that activation is blocked; the\nforeign daemon remains untouched, and the editor offers an explicit restore or\nretain-configuration choice.\n\n## 7. Use the Telegram chat\n\nThe managed daemon prefers Telegram forum-topic delivery for per-session routing\nin the paired private chat. When Threaded Mode is available for the bot (verified\nduring setup via `getMe.has_topics_enabled`), the daemon calls\n`createForumTopic`/`editForumTopic` and sends messages with `message_thread_id`\nagainst the paired `notifications.telegram.chatId`. If BotFather does not show\n**Threads Settings**/**Threaded Mode**, or if Telegram refuses topic creation even\nafter setup reported `threaded=verified`, the daemon routes notifications to the\nnormal (flat) paired private chat and posts a one-time nudge to enable Threaded\nMode rather than dropping them.\n\n### Ask-control capability negotiation\n\nThe production Telegram multiplexer is\n`packages/coding-agent/src/sdk/bus/telegram-daemon.ts`. It already sends a\nprotocol-v3 ClientHello with `ask_controls_v1` and `ask_selected_ack_v1`. The\ngeneric `packages/coding-agent/src/sdk/bus/managed-daemon.ts` is\nliveness-only: it advertises `client_ping_pong` but is intentionally\nnon-capable for controlled asks.\n\nTelegram navigation controls appear only after `ask_controls_v1` is negotiated\non that session connection. A non-capable or older third-party client receives\nthe non-actionable `action_unavailable` diagnostic instead of a controlled ask\nwith stripped option buttons, so it cannot be left with unusable controls.\n\nFlat private chat is notification-only plus inline ask buttons. It is not a\nfree-text chat surface: replies typed as normal messages and session commands such\nas `/verbose`, `/lean`, `/verbosity`, and `/redact` require Threaded Mode/topic\nrouting.\n\nFlat private-chat fallback preserves outbound notifications and inline-button\nanswers, but it cannot provide a separate Telegram topic per GJC session. Free-\ntext replies and in-topic config commands depend on topic routing, so enable\nThreaded Mode in @BotFather > Bot Settings > Threads Settings when you need\nmulti-session reply separation or session commands from Telegram. Do not\npair a group, supergroup, or channel as a substitute: setup intentionally accepts\nonly a private DM, and hand-edited non-private chat ids remain fail-closed to\navoid leaking session data. If you specifically want group topics, create a\nforum-enabled Telegram group and use a separate/custom notification integration;\nthe bundled `gjc notify setup` onboarding path is private-chat only.\n\nThe managed daemon can render:\n\n- session identity headers;\n- context updates;\n- live/finalized assistant output;\n- image attachments;\n- ask prompts with inline buttons;\n- activity/typing indicators;\n- inbound delivery acknowledgements.\n\nPer-tool activity is off by default so important notifications remain visible. This\nincludes `bash`, `read`, `task`, and subagent start/completion bubbles, including\nboth `ok` and `error` results. Send `/toolactivity on` in the paired private chat\nto opt in globally, or `/toolactivity off` to suppress these bubbles again. The\ntoggle is durable, works without an active GJC session, and has an equivalent\ncontrol under `/settings` → **Notifications** → **Preferences**. Turning it off\ndoes not affect assistant output, ask prompts, or session notifications.\n\nReply paths:\n\n- tap an inline button on an ask notification;\n- reply in the session topic with free text when forum-topic routing is\n available;\n- send in-topic config commands:\n - `/verbose` — per-tool-turn assistant text (and opt-in live streaming)\n - `/lean` — settled assistant answer when the agent reaches idle, plus immediate ask lead-ins (default; no intermediate tool-turn flood)\n - `/verbosity `\n - `/redact `\n - `/btw ` is available only in an authorized, known private-session\n topic. It uses the current session context in an isolated side turn and never\n injects or persists either a user or assistant message in the main session\n history, so it can run while the main session is busy. It accepts no\n attachments; `/btw` with an attachment returns `Usage: /btw `.\n Foreign bot-command suffixes are silently ignored.\n\n Each logical session permits at most two concurrent side questions. The host\n deadline is 120 seconds and cancels the actual provider work. Operational\n responses are: `Usage: /btw ` for an empty question; `Telegram\n /btw is disabled in local settings.` when disabled; `Restart this GJC session\n to enable /btw.` when the connected session does not support side turns; `Two\n /btw questions are already running. Wait for one to finish.` when busy; `This\n /btw question timed out after 120 seconds. Send it again to retry.` on\n timeout; `This /btw question stopped because the GJC session closed or\n changed. Reopen it and try again.` when stopped; and `This /btw question\n failed. Send it again to retry.` on failure.\n\n A transient reconnect to the exact session may deliver a result once.\n Graceful GJC or daemon shutdown cancels side questions. Crashes or identity\n changes do not promise delivery, and stale results are fenced.\n `/btw` rich replies use Telegram Bot API 10.1 Markdown only. An eligible,\n complete structured Markdown reply is sent once as\n `{rich_message:{markdown,skip_entity_detection:true}}`, correlated to the\n source message in the same topic; GJC does not send native `blocks` or\n `media`. Eligibility is conservative: valid Unicode; at most 32,768 scalars,\n 131,072 UTF-8 bytes, 500 blocks, 16 nesting levels, and 20 table columns.\n Tables and math use Telegram's 10.1 Markdown support. Ineligible content and\n a definite rich rejection use the existing correlated HTML delivery.\n Ambiguous rich outcomes never retry or fall back; `/rich off` keeps HTML-only\n behavior.\n- send paired-chat lifecycle commands from the Telegram command menu or by typing:\n - `/session_create path `\n - `/session_create worktree `\n - `/session_create dir `\n - `/session_recent [create|resume]`\n - `/session_close `\n - `/session_resume `\n\nThe removed legacy `/answer ` flow is not the primary UX;\nTelegram topic routing identifies the target session when the configured chat\nsupports it.\n### `/btw` operational rollback\n\n`notifications.telegram.btw.enabled` defaults to `true` and is the local kill\nswitch. Disabling it consumes `/btw` without forwarding it to the session. To\nroll back, restart the Telegram daemon, and probe health:\n\n```sh\ngjc config set notifications.telegram.btw.enabled false\ngjc daemon restart telegram --json\ngjc notify health --probe\n```\n\n## 8. Local `/notify` inside a session\n\nInside a running GJC session, `/notify` controls the current session only; it\ndoes not edit global config or credentials:\n\n- `/notify status` reports current session notification status without secrets;\n- `/notify off` disables the current session endpoint and removes its discovery record without changing global setup;\n- `/notify on` explicitly re-enables the current generic session when a complete effective provider or another explicit environment path is available.\n\n`GJC_NOTIFICATIONS=0` suppresses automatic generic current-session admission only. An explicit `/notify on` may override that one automatic-admission suppression for the current session; it does not alter durable provider intent or enable a direct provider API. `GJC_NOTIFY=off`, `0`, or `false` remains the hard process-level opt-out and exposes no notification control surface to override.\n\n## 9. Debug-only manual bridge\n\nThe manual Telegram CLI remains a reference/debug tool:\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nIf a fresh managed daemon already owns the same bot token and paired chat, the\nmanual CLI refuses to start by default because a second poller would cause\nTelegram `409 Conflict`. Use `--force` only for deliberate debugging after you\nunderstand which daemon owns polling.\n\n## Troubleshooting\n\n### `Telegram getMe failed`\n\nThe BotFather token is invalid or was revoked. Re-copy the token from BotFather\nor regenerate it in the official BotFather UI.\n\n### Setup times out waiting for a private chat\n\nSend any message directly to the bot from your Telegram user account. Do not add\nit to a group for pairing; groups/supergroups/channels are intentionally rejected\nby the current setup flow.\n\n### Setup succeeds but no Telegram session messages arrive\n\nCheck the `threaded=` status from the last `gjc notify setup` run. If it is\n`threaded=unverified` or `threaded=unknown`, first try the current Telegram\nclient's @BotFather flow for this bot. If BotFather's **Bot Settings** menu lacks\n**Threads Settings**/**Threaded Mode**, continue with the saved private-chat\npairing; this is supported. GJC cannot enable Threaded Mode through the Bot API,\nand no paid/Stars option is required just to receive flat private-chat\nnotifications. When `createForumTopic` is refused for the paired chat, the daemon\nfalls back to flat delivery in the paired private chat and posts a one-time nudge\nthat points to @BotFather > Bot Settings > Threads Settings. Flat fallback is\nlimited to outbound notifications and inline ask buttons; free-text replies and\nsession commands require Threaded Mode/topic routing.\n\n### Third-party or older client lacks ask controls\n\nA custom client that omits ClientHello, or sends one without `ask_controls_v1`,\nwill still receive ordinary empty-controls asks but receives\n`action_unavailable` for controlled asks after the short Hello grace or explicit\nnon-capable negotiation. Upgrade it to send\n`{ \"type\": \"hello\", \"protocolVersion\": 3, \"capabilities\": [\"ask_controls_v1\"] }`\non each WebSocket open; reconnecting starts a new negotiation.\n\n### Telegram 409 conflict\n\nOnly one `getUpdates` poller can own a bot token. GJC never takes over a fresh\nforeign or unknown owner. If you own the other process, stop or reconfigure it,\nthen use `gjc notify health`, `gjc notify recovery`, or `gjc notify reconnect`;\nrecovery removes only dead-owner artifacts and never touches a live owner.\n\n### A session does not send notifications\n\nCheck, in order:\n\n1. `gjc notify status` and confirm the selected provider is complete, not quarantined, desired on, and effective\n2. the session has not run `/notify off`; when `GJC_NOTIFICATIONS=0` suppresses automatic admission, run `/notify on` explicitly\n3. the repo has `.gjc/state/sdk/.json`, or `.gjc/state/chat/sdk/.json` when a proven foreign Telegram owner is isolated while Discord/Slack remains effective\n4. the selected provider runtime is ready or attached\n5. the managed daemon state is fresh under the GJC agent notifications directory\n\nDo not paste endpoint discovery files into public issues; they contain the\nper-session WebSocket token needed by clients.\n", + "telegram-onboarding.md": "# Telegram notification onboarding\n\nThis guide documents the bundled Telegram notification setup path from Gajae-Code\nsource. In an interactive GJC session, use `/settings` → **Notifications** as the\nrecommended path; `gjc notify` remains the authoritative headless and automation\nfallback. It is for the managed reference client, not a separate remote-control\nproduct.\n\n## What you are setting up\n\nGajae-Code notifications are a loopback WebSocket SDK plus a managed Telegram\nreference daemon:\n\n- each GJC session publishes a local notification endpoint under\n `.gjc/state/sdk/.json`;\n- the managed Telegram daemon scans those endpoints, connects to them, and sends\n action-needed events to the configured Telegram chat;\n- replies and inline button taps route back to the exact session/action through\n the same notification protocol. When the configured chat supports Telegram\n forum topics, each session is routed through its own topic.\n\nThe setup command stores global notification settings in your GJC agent config\nand later sessions auto-connect when notifications are enabled.\n\n## 1. Create a Telegram bot with BotFather\n\nUse Telegram's official BotFather flow to create a bot and copy its HTTP API\ntoken:\n\n- Official BotFather documentation: \n- General Telegram Bot API documentation: \n\nIn Telegram, open `@BotFather`, run `/newbot`, choose a display name and a unique\nusername ending in `bot`, then copy the token BotFather returns. Treat the token\nlike a password: do not paste it into logs, screenshots, issues, or shell history\nthat other people can read.\n\n## 2. Configure from `/settings` (recommended)\n\nIn an eligible running GJC session, open `/settings` and select the\n**Notifications** tab. It provides the interactive Telegram setup/reconfigure\nflow and the operational controls in one place:\n\n- Enable globally with stored credentials or disable globally;\n- turn notifications on or off for the current session only;\n- refresh or probe health, send a test notification, recover dead-owner\n artifacts, and reconnect the Telegram runtime;\n- remove Telegram credentials without removing configured Discord or Slack\n adapters.\n\nTelegram token entry is a masked setup field. After entry, the token is never\nprefilled, rendered, or shown by the tab; status and health use a masked value.\nThe tab also guides the BotFather Threaded Mode check and private-chat pairing.\n\n### CLI setup fallback\n\n`gjc notify setup` retains the same setup workflow for terminal-driven setup and\nautomation:\n\n```sh\ngjc notify setup\n```\n\nCurrent implementation path: `packages/coding-agent/src/cli/notify-cli.ts`.\n\nThe wizard does this:\n\n1. prompts for `Telegram BotFather token:`;\n2. validates the token with Telegram `getMe`;\n3. verifies private-chat Threaded Mode capability via `getMe.has_topics_enabled`\n and, when it is off in an interactive run, prints @BotFather guidance and\n lets you retry or continue unverified;\n4. asks you to message the bot from a private Telegram chat;\n5. polls Telegram `getUpdates` until it sees a private chat message;\n6. writes the paired chat id and enables notifications.\n\nThe setup pairing flow is private-chat only. If setup sees a `group`,\n`supergroup`, or `channel`, it rejects that chat and keeps waiting for a private\nDM. This is intentional for safe local discovery: group chats must not receive\nsession names, action ids, or pending status by accident.\n\n\nTelegram private-chat topics: the managed daemon's per-session delivery uses\nTelegram forum topics (`createForumTopic` + `message_thread_id`). Telegram now\nsupports forum topics in **private chats** when the bot owner enables **Threaded\nMode** for the bot in @BotFather. GJC cannot enable Threaded Mode through the Bot\nAPI; setup only detects the capability (`getMe.has_topics_enabled`) and guides the\nmanual BotFather toggle. A forum-enabled supergroup is no longer required.\n\nNote: enabling topics in private chats may require an additional Telegram Stars\npurchase fee, per Telegram's Terms of Service for Bot Developers.\n\nIf BotFather's **Bot Settings** menu does not show **Threads Settings** or\n**Threaded Mode**, do not treat that as a setup blocker. Telegram exposes this\ncapability unevenly across clients/accounts/bot states, and GJC cannot force the\nmenu to appear through the Bot API. The safe fallback is to continue setup with a\nprivate DM pairing: choose `skip` in the interactive prompt (or use\n`--token --chat-id ` for non-interactive setup). GJC will save\n`threaded=unverified`/`threaded=unknown`, try topics at runtime when possible,\nand otherwise deliver flat to the paired private chat with outbound notifications\nand inline ask buttons only plus the one-time nudge shown below.\n\nSetup verification is capability verification, not a delivery guarantee: even when\nsetup reports `threaded=verified`, the first runtime `createForumTopic` for the\npaired chat can still fail if Telegram refuses it. When per-session topics are\nunavailable, the daemon does **not** drop notifications — it routes them to the\nnormal (flat) paired chat and posts a one-time nudge: `Flat Telegram private chat\nsupports outbound notifications and inline ask buttons only. Enable Threaded Mode\nin @BotFather > Bot Settings > Threads Settings for free-text replies and session\ncommands.` Because pairing is private-only, flat delivery lands in your own\nprivate DM with the bot.\n\nThe final setup line reports a `threaded=` status:\n\n- `threaded=verified`: the bot has Threaded Mode capability (`has_topics_enabled`\n was true during setup);\n- `threaded=unverified`: Threaded Mode was off and you skipped, or setup ran\n non-interactively; setup is saved, topics are attempted when available, and\n runtime delivery falls back to the paired flat private chat with outbound\n notifications and inline ask buttons only when Telegram refuses topic creation;\n- `threaded=unknown`: the Telegram response did not include `has_topics_enabled`,\n so capability could not be verified.\n\nAfter setup succeeds, it prints a masked token and the paired chat id:\n\n```text\nNotifications enabled. botToken=1234…(len N) chatId=123456789 threaded=verified\n```\n\nThe raw token is never printed by GJC status/setup output after it is stored.\n\n## 3. Non-interactive setup and CLI operations\n\nFor headless provisioning, scripts, and automation, the authoritative commands\nremain `gjc notify setup`, `gjc notify status`, `gjc notify health`, `gjc notify\ntest`, and `gjc notify recovery`. The `/settings` tab does not replace these CLI\nsubcommands.\n\nFor scripts or CI-style local provisioning, pass the bot token and known private\nchat id explicitly. Non-interactive runs cannot prompt for the BotFather toggle,\nso if Threaded Mode is off (or the capability is unknown) setup is still saved\nwith a warning and a `threaded=unverified`/`threaded=unknown` status:\n\n```sh\ngjc notify setup --token --chat-id \n```\n\nOptional redaction can be enabled during setup:\n\n```sh\ngjc notify setup --token --chat-id --redact\n```\n\n`--redact` sets `notifications.redact = true`. Under redaction, idle summaries\nand streamed content are suppressed before remote delivery, but ask questions and\noptions remain readable because they must be answerable remotely.\n\n## 4. Check status without leaking secrets\n\n```sh\ngjc notify status\n```\n\nThe status command reports the global master plus each provider's independent\nconfiguration completeness, repair/quarantine state, durable desired-intent\nsource, and effective enablement. Stored tokens are masked with the shared\n`first 4 chars + … + length` helper. Destination identifiers such as Telegram\nchat IDs remain visible and may be sensitive, so redact them before pasting a\nstatus report into a public support thread. Runtime readiness and actual\ndelivery outcomes remain separate; use `gjc notify health --provider telegram`\nand `gjc notify test --provider telegram` for those checks.\n\n## 5. Global configuration, adapters, and precedence\n\nTelegram credentials and all `notifications.*` values are **global-only**. GJC\nreads them from the user/global agent config with schema defaults; notification\nkeys from project config files are ignored, and runtime notification overrides\nare rejected. A project cannot supply, shadow, or disable an outbound\nnotification identity.\n\n`gjc notify setup` writes these global Telegram settings through the GJC Settings\nlayer:\n\n- `notifications.enabled = true`\n- `notifications.telegram.enabled = true` (durable desired intent)\n- `notifications.telegram.botToken = `\n- `notifications.telegram.chatId = `\n- `notifications.redact = true` only when `--redact` was passed\n- `notifications.telegram.streaming.enabled = true` by default; set it to `false` to disable durable live Telegram assistant-output updates globally. `GJC_NOTIFICATIONS_STREAM=1` forces process-local streaming, while `0`, `off`, or `false` forces it off.\n\nProvider completeness, malformed-state quarantine, desired intent, effective enablement, runtime readiness, and delivery outcome are separate status dimensions. Telegram is complete when its bot token and private-chat id are valid; it is effective only when it is complete, not quarantined, desired on, and the global master is on. Provider-local malformed values are quarantined without erasing safe sibling values or secrets. Removing Telegram is adapter-local: it removes only Telegram credentials and sets Telegram desired intent off without changing `notifications.enabled` or any Discord/Slack state.\n\n\nThree lifecycle gates keep SDK hosting, setup, and managed delivery separate:\n\n1. An eligible host receives the dormant notification control surface. `GJC_NOTIFY=off`,\n `0`, or `false` is a hard process opt-out; unsupported hosts and\n helper/subagent sessions are also ineligible.\n2. Every eligible top-level session hosts its local SDK endpoint by default,\n independently of notification configuration. `GJC_SDK_DISABLE=1` opts out of\n SDK hosting for that session.\n3. A managed Telegram daemon is ensured only for a complete global Telegram\n configuration with managed delivery enabled. Discord-only, Slack-only, and\n environment-only sessions do not start a Telegram daemon.\n\nEnvironment/session precedence for managed delivery is implemented in\n`packages/coding-agent/src/sdk/bus/config.ts`:\n\nFor a GJC-spawned child, `notifications.sessionScope=primary` suppresses managed\nnotification delivery to avoid duplicate topics; `all` permits it.\n`GJC_NOTIFICATIONS=1` or `GJC_NOTIFICATIONS_TOKEN` explicitly opts that child in,\nbut never overrides a hard opt-out or a helper/subagent exclusion.\n\nManaged-delivery precedence is highest first; it does not change independently\nhosted SDK endpoints:\n\n1. `GJC_NOTIFY=off`, `0`, or `false` prevents the notification control surface\n for that process.\n2. `GJC_NOTIFICATIONS=0` suppresses automatic generic current-session admission; explicit `/notify on` may override that suppression only for the current session.\n3. Local `/notify off` disables managed delivery only for the current session.\n4. `GJC_NOTIFICATIONS=1` or `GJC_NOTIFICATIONS_TOKEN` enables the legacy\n explicit managed-delivery path.\n5. A complete global configuration enables managed delivery automatically.\n6. Otherwise managed delivery stays off; the SDK endpoint remains hosted unless\n `GJC_SDK_DISABLE=1` is set.\n\n## 6. Start or reuse sessions\n\nAfter setup, start GJC normally:\n\n```sh\ngjc --tmux\n```\n\nor use any other supported GJC launch mode. Every eligible top-level session\nwrites its SDK endpoint unless `GJC_SDK_DISABLE=1`; when managed Telegram\ndelivery is configured and enabled, it also ensures the Telegram daemon is running.\n\nThe managed daemon is a singleton per bot token/chat pair. Telegram allows only\none active `getUpdates` long-poll owner for a bot token, so GJC keeps a local\ndaemon lock/state file and makes later sessions attach to the fresh owner instead\nof starting a second poller. This avoids Telegram `409 Conflict` failures.\n\n### Same-token and foreign-owner safety\n\nSetup and reconfigure never compete with a live same-token daemon. When a live\nowner already has the stored paired chat, GJC reuses it after non-polling\nvalidation. If that owner has no stored chat or the chat changes, provide a\nvalidated private chat id; GJC performs zero `getUpdates` discovery polls. For a\nforeign or unknown owner, setup does not poll, kill, reload, or take over the\nowner; the default is to cancel before writing configuration.\n\nFor a Telegram-only setup, an explicit **Save inactive for later** choice may\nstore the credentials with notifications disabled. That choice is unavailable\nwhen a complete Discord or Slack adapter is active, because globally disabling\nnotifications would affect that adapter. A post-save identity race similarly\nstops the current session before reporting that activation is blocked; the\nforeign daemon remains untouched, and the editor offers an explicit restore or\nretain-configuration choice.\n\n## 7. Use the Telegram chat\n\nThe managed daemon prefers Telegram forum-topic delivery for per-session routing\nin the paired private chat. When Threaded Mode is available for the bot (verified\nduring setup via `getMe.has_topics_enabled`), the daemon calls\n`createForumTopic`/`editForumTopic` and sends messages with `message_thread_id`\nagainst the paired `notifications.telegram.chatId`. If BotFather does not show\n**Threads Settings**/**Threaded Mode**, or if Telegram refuses topic creation even\nafter setup reported `threaded=verified`, the daemon routes notifications to the\nnormal (flat) paired private chat and posts a one-time nudge to enable Threaded\nMode rather than dropping them.\n\n### Ask-control capability negotiation\n\nThe production Telegram multiplexer is\n`packages/coding-agent/src/sdk/bus/telegram-daemon.ts`. It already sends a\nprotocol-v3 ClientHello with `ask_controls_v1` and `ask_selected_ack_v1`. The\ngeneric `packages/coding-agent/src/sdk/bus/managed-daemon.ts` is\nliveness-only: it advertises `client_ping_pong` but is intentionally\nnon-capable for controlled asks.\n\nTelegram navigation controls appear only after `ask_controls_v1` is negotiated\non that session connection. A non-capable or older third-party client receives\nthe non-actionable `action_unavailable` diagnostic instead of a controlled ask\nwith stripped option buttons, so it cannot be left with unusable controls.\n\nFlat private chat is notification-only plus inline ask buttons. It is not a\nfree-text chat surface: replies typed as normal messages and session commands such\nas `/verbose`, `/lean`, `/verbosity`, and `/redact` require Threaded Mode/topic\nrouting.\n\nFlat private-chat fallback preserves outbound notifications and inline-button\nanswers, but it cannot provide a separate Telegram topic per GJC session. Free-\ntext replies and in-topic config commands depend on topic routing, so enable\nThreaded Mode in @BotFather > Bot Settings > Threads Settings when you need\nmulti-session reply separation or session commands from Telegram. Do not\npair a group, supergroup, or channel as a substitute: setup intentionally accepts\nonly a private DM, and hand-edited non-private chat ids remain fail-closed to\navoid leaking session data. If you specifically want group topics, create a\nforum-enabled Telegram group and use a separate/custom notification integration;\nthe bundled `gjc notify setup` onboarding path is private-chat only.\n\nThe managed daemon can render:\n\n- session identity headers;\n- context updates;\n- live/finalized assistant output;\n- image attachments;\n- ask prompts with inline buttons;\n- activity/typing indicators;\n- inbound delivery acknowledgements.\n\nPer-tool activity is off by default so important notifications remain visible. This\nincludes `bash`, `read`, `task`, and subagent start/completion bubbles, including\nboth `ok` and `error` results. Send `/toolactivity on` in the paired private chat\nto opt in globally, or `/toolactivity off` to suppress these bubbles again. The\ntoggle is durable, works without an active GJC session, and has an equivalent\ncontrol under `/settings` → **Notifications** → **Preferences**. Turning it off\ndoes not affect assistant output, ask prompts, or session notifications.\n\nReply paths:\n\n- tap an inline button on an ask notification;\n- reply in the session topic with free text when forum-topic routing is\n available;\n- send in-topic config commands:\n - `/verbose` — per-tool-turn assistant text (and opt-in live streaming)\n - `/lean` — settled assistant answer when the agent reaches idle, plus immediate ask lead-ins (default; no intermediate tool-turn flood)\n - `/verbosity `\n - `/redact `\n - `/btw ` is available only in an authorized, known private-session\n topic. It uses the current session context in an isolated side turn and never\n injects or persists either a user or assistant message in the main session\n history, so it can run while the main session is busy. It accepts no\n attachments; `/btw` with an attachment returns `Usage: /btw `.\n Foreign bot-command suffixes are silently ignored.\n\n Each logical session permits at most two concurrent side questions. The host\n deadline is 120 seconds and cancels the actual provider work. Operational\n responses are: `Usage: /btw ` for an empty question; `Telegram\n /btw is disabled in local settings.` when disabled; `Restart this GJC session\n to enable /btw.` when the connected session does not support side turns; `Two\n /btw questions are already running. Wait for one to finish.` when busy; `This\n /btw question timed out after 120 seconds. Send it again to retry.` on\n timeout; `This /btw question stopped because the GJC session closed or\n changed. Reopen it and try again.` when stopped; and `This /btw question\n failed. Send it again to retry.` on failure.\n\n A transient reconnect to the exact session may deliver a result once.\n Graceful GJC or daemon shutdown cancels side questions. Crashes or identity\n changes do not promise delivery, and stale results are fenced.\n `/btw` rich replies use Telegram Bot API 10.1 Markdown only. An eligible,\n complete structured Markdown reply is sent once as\n `{rich_message:{markdown,skip_entity_detection:true}}`, correlated to the\n source message in the same topic; GJC does not send native `blocks` or\n `media`. Eligibility is conservative: valid Unicode; at most 32,768 scalars,\n 131,072 UTF-8 bytes, 500 blocks, 16 nesting levels, and 20 table columns.\n Tables and math use Telegram's 10.1 Markdown support. Ineligible content and\n a definite rich rejection use the existing correlated HTML delivery.\n Ambiguous rich outcomes never retry or fall back; `/rich off` keeps HTML-only\n behavior.\n- send paired-chat lifecycle commands from the Telegram command menu or by typing:\n - `/session_create path `\n - `/session_create worktree `\n - `/session_create dir `\n - `/session_recent [create|resume]`\n - `/session_close `\n - `/session_resume `\n\nThe removed legacy `/answer ` flow is not the primary UX;\nTelegram topic routing identifies the target session when the configured chat\nsupports it.\n### `/btw` operational rollback\n\n`notifications.telegram.btw.enabled` defaults to `true` and is the local kill\nswitch. Disabling it consumes `/btw` without forwarding it to the session. To\nroll back, restart the Telegram daemon, and probe health:\n\n```sh\ngjc config set notifications.telegram.btw.enabled false\ngjc daemon restart telegram --json\ngjc notify health --probe\n```\n\n## 8. Local `/notify` inside a session\n\nInside a running GJC session, `/notify` controls the current session only; it\ndoes not edit global config or credentials:\n\n- `/notify status` reports current session notification status without secrets;\n- `/notify off` disables the current session endpoint and removes its discovery record without changing global setup;\n- `/notify on` explicitly re-enables the current generic session when a complete effective provider or another explicit environment path is available.\n\n`GJC_NOTIFICATIONS=0` suppresses automatic generic current-session admission only. An explicit `/notify on` may override that one automatic-admission suppression for the current session; it does not alter durable provider intent or enable a direct provider API. `GJC_NOTIFY=off`, `0`, or `false` remains the hard process-level opt-out and exposes no notification control surface to override.\n\n## 9. Debug-only manual bridge\n\nThe manual Telegram CLI remains a reference/debug tool:\n\n```sh\nbun run packages/coding-agent/src/sdk/bus/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nIf a fresh managed daemon already owns the same bot token and paired chat, the\nmanual CLI refuses to start by default because a second poller would cause\nTelegram `409 Conflict`. Use `--force` only for deliberate debugging after you\nunderstand which daemon owns polling.\n\n## Troubleshooting\n\n### `Telegram getMe failed`\n\nThe BotFather token is invalid or was revoked. Re-copy the token from BotFather\nor regenerate it in the official BotFather UI.\n\n### Setup times out waiting for a private chat\n\nSend any message directly to the bot from your Telegram user account. Do not add\nit to a group for pairing; groups/supergroups/channels are intentionally rejected\nby the current setup flow.\n\n### Setup succeeds but no Telegram session messages arrive\n\nCheck the `threaded=` status from the last `gjc notify setup` run. If it is\n`threaded=unverified` or `threaded=unknown`, first try the current Telegram\nclient's @BotFather flow for this bot. If BotFather's **Bot Settings** menu lacks\n**Threads Settings**/**Threaded Mode**, continue with the saved private-chat\npairing; this is supported. GJC cannot enable Threaded Mode through the Bot API,\nand no paid/Stars option is required just to receive flat private-chat\nnotifications. When `createForumTopic` is refused for the paired chat, the daemon\nfalls back to flat delivery in the paired private chat and posts a one-time nudge\nthat points to @BotFather > Bot Settings > Threads Settings. Flat fallback is\nlimited to outbound notifications and inline ask buttons; free-text replies and\nsession commands require Threaded Mode/topic routing.\n\n### Third-party or older client lacks ask controls\n\nA custom client that omits ClientHello, or sends one without `ask_controls_v1`,\nwill still receive ordinary empty-controls asks but receives\n`action_unavailable` for controlled asks after the short Hello grace or explicit\nnon-capable negotiation. Upgrade it to send\n`{ \"type\": \"hello\", \"protocolVersion\": 3, \"capabilities\": [\"ask_controls_v1\"] }`\non each WebSocket open; reconnecting starts a new negotiation.\n\n### Telegram 409 conflict\n\nOnly one `getUpdates` poller can own a bot token. GJC never takes over a fresh\nforeign or unknown owner. If you own the other process, stop or reconfigure it,\nthen use `gjc notify health`, `gjc notify recovery`, or `gjc notify reconnect`;\nrecovery removes only dead-owner artifacts and never touches a live owner.\n\n### A session does not send notifications\n\nCheck, in order:\n\n1. `gjc notify status` and confirm the selected provider is complete, not quarantined, desired on, and effective\n2. the session has not run `/notify off`; when `GJC_NOTIFICATIONS=0` suppresses automatic admission, run `/notify on` explicitly\n3. the repo has `.gjc/state/sdk/.json`, or `.gjc/state/chat/sdk/.json` when a proven foreign Telegram owner is isolated while Discord/Slack remains effective\n4. the selected provider runtime is ready or attached\n5. the managed daemon state is fresh under the GJC agent notifications directory\n\nDo not paste endpoint discovery files into public issues; they contain the\nper-session WebSocket token needed by clients.\n", "telegram-session-close-timeout-bug.md": "# Telegram `/session_close` uncertain outcome and delayed topic cleanup\n\n## Baseline\n\n- Branch: `fix/telegram-session-close-timeout`\n- Base: `upstream/dev` at `12aa7ebd18752c338b55a6ddc0ca8945f6e555cb`\n- Reported: 2026-07-22\n\n## Reproduction\n\n1. Create a GJC session from Telegram and wait until its topic/session is active.\n2. Send:\n\n```text\n/session_close \n```\n\n3. Observe the close response, process/session liveness, and Telegram topic lifecycle.\n\n## Expected behavior\n\n- A valid managed session ID is resolved deterministically.\n- The close request terminates the target session promptly.\n- The daemon returns one clear terminal close result.\n- The Telegram topic/thread is deleted promptly after the session reaches the terminal state.\n- A timeout is reserved for a genuinely unresponsive close operation, not the normal successful path.\n\n## Observed behavior\n\n- Telegram displays `Close outcome uncertain. The session may already be closed — check /session_recent before retrying.`\n- The target process appears to terminate, but the close request does not receive authoritative terminal confirmation.\n- The Telegram topic remains visible for approximately 60 seconds.\n- The topic is then deleted by the orphan-topic cleanup path after `ORPHAN_TOPIC_GRACE_MS`, rather than promptly by the authenticated `session_closed` handler.\n\nThe warning does not mean the session is confirmed closed. It means the close effect may have occurred, but the daemon could not prove the terminal result. The delayed deletion indicates that normal terminal cleanup was missed and the 60-second orphan fallback recovered it later.\n\n## Investigation focus\n\nTrace one lifecycle request ID across:\n\n- Telegram command parsing and acknowledgement\n- `session_close` lifecycle frame dispatch\n- managed tmux/session identity resolution\n- force-close SIGTERM, owner-verdict, and compatibility cleanup ordering\n- owner/supervisor terminal-state observation\n- close outcome generation\n- Telegram topic deletion\n\nPay particular attention to ordering. The managed owner must publish its immutable terminal verdict before runtime-state serialization, coordinator/state-file locks, and terminal-payload preservation can delay or return from postmortem handling. Topic cleanup remains an independent path: it must follow an authenticated `session_closed` frame for the current endpoint generation and lease, never a lifecycle acknowledgement alone. Also verify that the supplied session ID maps to the actual managed tmux name and generation.\n\n## Regression coverage\n\nAdd focused tests for:\n\n1. A live managed session closes before the timeout and emits one terminal outcome.\n2. Topic deletion occurs after terminal close evidence, without waiting for the timeout.\n3. A session that exits during the close race is treated idempotently as closed.\n4. Repeating the same close request returns the prior terminal result without another timeout.\n5. Unknown and unmanaged session IDs fail closed without deleting unrelated topics.\n6. A genuinely stuck process reaches the bounded force-close path and reports that distinct outcome.\n\n## Acceptance criteria\n\n- `/session_close ` makes the managed session non-live promptly under normal conditions.\n- The normal path does not display an intermediate outcome that remains pending until timeout.\n- Topic deletion is prompt, deterministic, and tied to the correct session generation.\n- Timeout/force-close remains bounded and observable for genuinely unresponsive sessions.\n- Close remains replay-safe and cannot kill a reused tmux session belonging to another generation.\n", "theme.md": "# Theming Reference\n\nThis document describes how theming works in the coding-agent today: schema, loading, runtime behavior, and failure modes.\n\n## What the theme system controls\n\nThe theme system drives:\n\n- foreground/background color tokens used across the TUI\n- markdown styling adapters (`getMarkdownTheme()`)\n- selector/editor/settings list adapters (`getSelectListTheme()`, `getEditorTheme()`, `getSettingsListTheme()`)\n- symbol preset + symbol overrides (`unicode`, `nerd`, `ascii`)\n- syntax highlighting colors used by native highlighter (`@gajae-code/natives`)\n- status line segment colors\n\nPrimary implementation: `src/modes/theme/theme.ts`.\n\n## Theme JSON shape\n\nTheme files are JSON objects validated against the runtime schema in `theme.ts` (`ThemeJsonSchema`) and mirrored by `src/modes/theme/theme-schema.json`.\n\nTop-level fields:\n\n- `name` (required)\n- `colors` (required; all color tokens required)\n- `vars` (optional; reusable color variables)\n- `export` (optional; HTML export colors)\n- `symbols` (optional)\n - `preset` (optional: `unicode | nerd | ascii`)\n - `overrides` (optional: key/value overrides for `SymbolKey`)\n\nColor values accept:\n\n- hex string (`\"#RRGGBB\"`)\n- 256-color index (`0..255`)\n- variable reference string (resolved through `vars`)\n- empty string (`\"\"`) meaning terminal default (`\\x1b[39m` fg, `\\x1b[49m` bg)\n\n## Required color tokens (current)\n\nAll tokens below are required in `colors`.\n\n### Core text and borders (11)\n\n`accent`, `border`, `borderAccent`, `borderMuted`, `success`, `error`, `warning`, `muted`, `dim`, `text`, `thinkingText`\n\n### Background blocks (7)\n\n`selectedBg`, `userMessageBg`, `customMessageBg`, `toolPendingBg`, `toolSuccessBg`, `toolErrorBg`, `statusLineBg`\n\n### Message/tool text (5)\n\n`userMessageText`, `customMessageText`, `customMessageLabel`, `toolTitle`, `toolOutput`\n\n### Markdown (10)\n\n`mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet`\n\n### Tool diff + syntax highlighting (12)\n\n`toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext`,\n`syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation`\n\n### Mode/thinking borders (8)\n\n`thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh`, `bashMode`, `pythonMode`\n\n### Status line segment colors (14)\n\n`statusLineSep`, `statusLineModel`, `statusLinePath`, `statusLineGitClean`, `statusLineGitDirty`, `statusLineContext`, `statusLineSpend`, `statusLineStaged`, `statusLineDirty`, `statusLineUntracked`, `statusLineOutput`, `statusLineCost`, `statusLineSubagents`\n\n## Optional tokens\n\n### `export` section (optional)\n\nUsed for HTML export theming helpers:\n\n- `export.pageBg`\n- `export.cardBg`\n- `export.infoBg`\n\nIf omitted, export code derives defaults from resolved theme colors.\n\n### `symbols` section (optional)\n\n- `symbols.preset` sets a theme-level default symbol set.\n- `symbols.overrides` can override individual `SymbolKey` values.\n\nRuntime precedence:\n\n1. settings `symbolPreset` override (if set)\n2. theme JSON `symbols.preset`\n3. fallback `\"unicode\"`\n\nInvalid override keys are ignored and logged (`logger.debug`).\n\n## Built-in vs custom theme sources\n\nTheme lookup order (`loadThemeJson`):\n\n1. built-in embedded themes (`red-claw.json`, `blue-crab.json`, `claude-code.json`, `codex.json`, and `opencode.json` compiled into `defaultThemes`)\n2. custom theme file: `/.json`\n\nCustom themes directory comes from `getCustomThemesDir()`:\n\n- default: `~/.gjc/agent/themes`\n- overridden by `GJC_CODING_AGENT_DIR` (`$GJC_CODING_AGENT_DIR/themes`)\n\n`getAvailableThemes()` returns merged built-in + custom names, sorted, with built-ins taking precedence on name collision.\n\n## Loading, validation, and resolution\n\nFor custom theme files:\n\n1. read JSON\n2. parse JSON\n3. validate against `ThemeJsonSchema`\n4. resolve `vars` references recursively\n5. convert resolved values to ANSI by terminal capability mode\n\nValidation behavior:\n\n- missing required color tokens: explicit grouped error message\n- bad token types/values: validation errors with JSON path\n- unknown theme file: `Theme not found: `\n\nVar reference behavior:\n\n- supports nested references\n- throws on missing variable reference\n- throws on circular references\n\n## Terminal color mode behavior\n\nColor mode detection (`detectColorMode`):\n\n- `COLORTERM=truecolor|24bit` => truecolor\n- `WT_SESSION` => truecolor\n- `TERM` in `dumb`, `linux`, or empty => 256color\n- otherwise => truecolor\n\nConversion behavior:\n\n- hex -> `Bun.color(..., \"ansi-16m\" | \"ansi-256\")`\n- numeric -> `38;5` / `48;5` ANSI\n- `\"\"` -> default fg/bg reset\n\n## Runtime switching behavior\n\n### Initial theme (`initTheme`)\n\n`main.ts` initializes theme with settings:\n\n- `symbolPreset`\n- `colorBlindMode`\n- `theme.dark`\n- `theme.light`\n\nAuto theme slot selection uses terminal appearance in this order:\n\n1. terminal-reported OSC 11 background luminance, unless the macOS/Zellij fallback path is active\n2. `COLORFGBG` background index (`< 8` => dark, `>= 8` => light)\n3. macOS appearance fallback only for the known-broken macOS/Zellij OSC 11 path\n4. dark slot fallback\n\nBuilt-in theme note: `red-claw` is the default dark GJC theme, and `blue-crab` is the default light-slot theme. Both are crustacean brand themes with separate semantic error/warning/diff-removal tokens and crab-oriented symbol overrides. Three additional bundled migration themes — `claude-code`, `codex`, and `opencode` — mirror the look of those tools for easy eye-migration. All three are dark-classified and recommended for `theme.dark`, but are selectable in either slot; they keep GJC's default symbol identity (no crab-symbol overrides).\n\nCurrent defaults from settings schema:\n\n- `theme.dark = \"red-claw\"`\n- `theme.light = \"blue-crab\"`\n- `symbolPreset = \"unicode\"`\n- `colorBlindMode = false`\n\n### Explicit switching (`setTheme`)\n\n- loads selected theme\n- updates global `theme` singleton\n- optionally starts watcher\n- triggers `onThemeChange` callback\n\nOn failure:\n\n- falls back to built-in `dark`\n- returns `{ success: false, error }`\n\n### Preview switching (`previewTheme`)\n\n- applies temporary preview theme to global `theme`\n- does **not** change persisted settings by itself\n- returns success/error without fallback replacement\n\nThe settings theme picker is confirm-only; arrow-key browsing does not call `previewTheme`, so the rendered theme and displayed/persisted theme name stay aligned until Enter confirms a new selection.\n\n## Watchers and live reload\n\nWhen watcher is enabled (`setTheme(..., true)` / interactive init):\n\n- watches `/.json` only when that file exists\n- built-ins are effectively not watched; built-in theme lookup also takes precedence over same-name custom files\n- matching file changes schedule a debounced reload; reload errors or temporary file absence keep the last successfully loaded theme\n- the watcher does not perform a delete/rename fallback; it waits for a future successful reload or explicit theme switch\n\nAuto mode also reevaluates dark/light slot mapping from terminal appearance changes, `SIGWINCH`, and the macOS fallback observer when active.\n\n## Color-blind mode behavior\n\n`colorBlindMode` changes only one token at runtime:\n\n- `toolDiffAdded` is HSV-adjusted (green shifted toward blue)\n- adjustment is applied only when resolved value is a hex string\n\nOther tokens are unchanged.\n\n## Where theme settings are persisted\n\nTheme-related settings are persisted by `Settings` to global config YAML:\n\n- path: `/config.yml`\n- default agent dir: `~/.gjc/agent`\n- effective default file: `~/.gjc/agent/config.yml`\n\nPersisted keys:\n\n- `theme.dark`\n- `theme.light`\n- `symbolPreset`\n- `colorBlindMode`\n\nLegacy migration exists: old flat `theme: \"name\"` is migrated to nested `theme.dark` or `theme.light` based on luminance detection; legacy built-in names `dark`/`light` map to `red-claw`/`blue-crab` unless matching custom theme files exist.\n\n## Creating a custom theme (practical)\n\n1. Create file in custom themes dir, e.g. `~/.gjc/agent/themes/my-theme.json`.\n2. Include `name`, optional `vars`, and **all required** `colors` tokens.\n3. Optionally include `symbols` and `export`.\n4. Select the theme in Settings (`Display -> Dark theme` or `Display -> Light theme`) depending on which auto slot you want. All bundled themes are selectable: the crustacean defaults `red-claw` and `blue-crab`, plus the migration themes `claude-code`, `codex`, and `opencode` (dark-classified, recommended for the dark slot but selectable in either).\n\nMinimal skeleton:\n\n```json\n{\n \"name\": \"my-theme\",\n \"vars\": {\n \"accent\": \"#7aa2f7\",\n \"muted\": 244\n },\n \"colors\": {\n \"accent\": \"accent\",\n \"border\": \"#4c566a\",\n \"borderAccent\": \"accent\",\n \"borderMuted\": \"muted\",\n \"success\": \"#9ece6a\",\n \"error\": \"#f7768e\",\n \"warning\": \"#e0af68\",\n \"muted\": \"muted\",\n \"dim\": 240,\n \"text\": \"\",\n \"thinkingText\": \"muted\",\n\n \"selectedBg\": \"#2a2f45\",\n \"userMessageBg\": \"#1f2335\",\n \"userMessageText\": \"\",\n \"customMessageBg\": \"#24283b\",\n \"customMessageText\": \"\",\n \"customMessageLabel\": \"accent\",\n \"toolPendingBg\": \"#1f2335\",\n \"toolSuccessBg\": \"#1f2d2a\",\n \"toolErrorBg\": \"#2d1f2a\",\n \"toolTitle\": \"\",\n \"toolOutput\": \"muted\",\n\n \"mdHeading\": \"accent\",\n \"mdLink\": \"accent\",\n \"mdLinkUrl\": \"muted\",\n \"mdCode\": \"#c0caf5\",\n \"mdCodeBlock\": \"#c0caf5\",\n \"mdCodeBlockBorder\": \"muted\",\n \"mdQuote\": \"muted\",\n \"mdQuoteBorder\": \"muted\",\n \"mdHr\": \"muted\",\n \"mdListBullet\": \"accent\",\n\n \"toolDiffAdded\": \"#9ece6a\",\n \"toolDiffRemoved\": \"#f7768e\",\n \"toolDiffContext\": \"muted\",\n\n \"syntaxComment\": \"#565f89\",\n \"syntaxKeyword\": \"#bb9af7\",\n \"syntaxFunction\": \"#7aa2f7\",\n \"syntaxVariable\": \"#c0caf5\",\n \"syntaxString\": \"#9ece6a\",\n \"syntaxNumber\": \"#ff9e64\",\n \"syntaxType\": \"#2ac3de\",\n \"syntaxOperator\": \"#89ddff\",\n \"syntaxPunctuation\": \"#9aa5ce\",\n\n \"thinkingOff\": 240,\n \"thinkingMinimal\": 244,\n \"thinkingLow\": \"#7aa2f7\",\n \"thinkingMedium\": \"#2ac3de\",\n \"thinkingHigh\": \"#bb9af7\",\n \"thinkingXhigh\": \"#f7768e\",\n\n \"bashMode\": \"#2ac3de\",\n \"pythonMode\": \"#bb9af7\",\n\n \"statusLineBg\": \"#16161e\",\n \"statusLineSep\": 240,\n \"statusLineModel\": \"#bb9af7\",\n \"statusLinePath\": \"#7aa2f7\",\n \"statusLineGitClean\": \"#9ece6a\",\n \"statusLineGitDirty\": \"#e0af68\",\n \"statusLineContext\": \"#2ac3de\",\n \"statusLineSpend\": \"#7dcfff\",\n \"statusLineStaged\": \"#9ece6a\",\n \"statusLineDirty\": \"#e0af68\",\n \"statusLineUntracked\": \"#f7768e\",\n \"statusLineOutput\": \"#c0caf5\",\n \"statusLineCost\": \"#ff9e64\",\n \"statusLineSubagents\": \"#bb9af7\"\n }\n}\n```\n\n## Testing custom themes\n\nUse this workflow:\n\n1. Start interactive mode (watcher enabled from startup).\n2. Open settings and confirm the custom theme in the dark/light theme picker; arrow-key browsing is intentionally non-mutating.\n3. For custom theme files, edit the JSON while running and confirm auto-reload on save.\n4. Exercise critical surfaces:\n - markdown rendering\n - tool blocks (pending/success/error)\n - diff rendering (added/removed/context)\n - status line readability\n - thinking level border changes\n - bash/python mode border colors\n5. Validate both symbol presets if your theme depends on glyph width/appearance.\n\n## Real constraints and caveats\n\n- All `colors` tokens are required for custom themes.\n- `export` and `symbols` are optional.\n- `$schema` in theme JSON is informational; runtime validation is enforced by a Zod schema in code.\n- `setTheme` failure falls back to `dark`; `previewTheme` failure does not replace current theme.\n- File watcher reload errors or temporary missing files keep the current loaded theme until a successful reload or explicit theme switch.\n", "tools/ask.md": "# ask\n\n> Prompts the interactive user for one or more choices or free-form answers.\n\n## Source\n- Entry: `packages/coding-agent/src/tools/ask.ts`\n- Model-facing prompt: `packages/coding-agent/src/prompts/tools/ask.md`\n- Key collaborators:\n - `packages/coding-agent/src/config/settings-schema.ts` — `ask.timeout` / `ask.notify` defaults\n - `packages/coding-agent/src/modes/theme/theme.ts` — checkbox and tree glyphs for TUI rendering\n - `packages/coding-agent/src/tui.ts` — status-line rendering\n\n## Inputs\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `questions` | `Question[]` | Yes | One or more questions. Empty arrays are rejected by schema and also guarded at runtime. |\n\n### `Question`\n\n| Field | Type | Required | Description |\n| --- | --- | --- | --- |\n| `id` | `string` | Yes | Stable identifier used in multi-question results. |\n| `question` | `string` | Yes | Prompt text shown to the user. |\n| `options` | `{ label: string }[]` | Yes | Explicit options. The UI always appends `Other (type your own)`; callers must not include it. |\n| `multi` | `boolean` | No | Enables multi-select mode. Default: `false`. |\n| `recommended` | `number` | No | Zero-based recommended option index. In single-select mode the label gets ` (Recommended)` appended in the UI. |\n\n## Outputs\n- Single-shot result.\n- `content[0].text` is plain text:\n - single question: `User selected: ...` and/or `User provided custom input: ...`\n - multiple questions: `User answers:` followed by one line per `id`\n- `details`:\n - single question: `{ question, options, multi, selectedOptions, customInput? }`\n - multiple questions: `{ results: QuestionResult[] }`, where each item includes `id`, `question`, `options`, `multi`, `selectedOptions`, and optional `customInput`\n- Cancellation and headless cases throw instead of returning a structured success result.\n\n## Flow\n1. `AskTool.createIf()` only registers the tool when `session.hasUI` is true; headless sessions never get it.\n2. `execute()` requires `context.ui`; if missing it aborts the context and throws `ToolAbortError(\"Ask tool requires interactive mode\")`.\n3. It reads `ask.timeout` from settings, converts seconds to milliseconds, and disables timeout entirely while plan mode is enabled (`packages/coding-agent/src/tools/ask.ts`).\n4. If `ask.notify` is not `off`, it sends a terminal notification: `Waiting for input`.\n5. For each question, `askSingleQuestion()` drives either:\n - single-select list + optional editor for `Other`\n - multi-select checkbox loop + `Done selecting` sentinel + optional editor for `Other`\n6. In multi-question mode, left/right arrow handlers enable back/forward navigation between questions and preserve prior selections.\n7. If a timeout fires before any selection/custom input, the tool auto-selects the recommended option, or the first option when no valid `recommended` index exists.\n8. If the user cancels without timeout, `execute()` aborts the tool context and throws `ToolAbortError(\"Ask tool was cancelled by the user\")`.\n9. On success it formats human-readable text plus structured `details`; the TUI renderer uses `details` for rich display.\n\n## Modes / Variants\n- Single question: returns flattened `details` fields for one question.\n- Multiple questions: returns `details.results[]` and allows back/forward navigation across questions.\n- Single-select: one option or custom input.\n- Multi-select: toggled checkbox list, `Done selecting` sentinel only when forward navigation is not active.\n\n## Side Effects\n- User-visible prompts / interactive UI\n - Opens a selection dialog via `context.ui.select(...)`.\n - Opens a text editor dialog via `context.ui.editor(...)` for `Other`.\n - Sends a terminal notification unless `ask.notify=off`.\n- Session state\n - Reads plan-mode state to disable timeouts.\n - Calls `context.abort()` on headless use or user cancellation.\n- Background work / cancellation\n - Wraps UI waits in `untilAborted(...)` so abort signals interrupt pending dialogs.\n\n## Limits & Caps\n- `questions` must contain at least 1 item (`askSchema` in `packages/coding-agent/src/tools/ask.ts`).\n- `ask.timeout` default is `30` seconds; `0` disables timeout (`packages/coding-agent/src/config/settings-schema.ts`).\n- Prompt guidance says provide 2-5 options, but code does not enforce that (`packages/coding-agent/src/prompts/tools/ask.md`).\n- Timeout only applies to the option picker; once the user chooses `Other`, the editor has no timeout (`packages/coding-agent/src/prompts/tools/ask.md`).\n\n## Errors\n- Missing interactive UI: throws `ToolAbortError(\"Ask tool requires interactive mode\")`.\n- User cancels picker/editor without timeout: throws `ToolAbortError(\"Ask tool was cancelled by the user\")`.\n- Abort signal during input: converted to `ToolAbortError(\"Ask input was cancelled\")`.\n- Empty `questions` at runtime returns a text error payload instead of throwing: `Error: questions must not be empty`.\n\n## Notes\n- `recommended` is only a UI hint; invalid indexes are ignored.\n- In single-select mode the returned `selectedOptions` value strips the appended ` (Recommended)` suffix.\n- Multi-select results preserve selection order by `Set` insertion order, not original option order after arbitrary toggles.\n- Option labels and prompt text are returned verbatim in `details`; the tool does not interpret them beyond UI affordances like `Other` and ` (Recommended)`.\n", diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts index d34e9a8916..855fc0a6d8 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts @@ -14,6 +14,8 @@ import { import { daemonPaths, HEARTBEAT_TTL_MS } from "./daemon-paths"; import { type DaemonState, + FilesystemTopicRegistryCasAuthority, + loadInstallationHostId, readDaemonState, readOwnerFreshnessSnapshot, type TelegramDaemonOptions, @@ -45,6 +47,8 @@ export interface RunDaemonInternalDeps { clearInterval?: (timer: Timer) => void; /** Reads persisted daemon ownership state; defaults to the real reader. */ readDaemonState?: (settings: Settings) => Promise; + /** Loads the verified machine-local identity; injectable so daemon tests do not touch the host. */ + loadInstallationHostId?: () => Promise; } /** Ownership-watchdog cadence while the daemon process is running. */ @@ -231,6 +235,11 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD const settings = await resolveDaemonSettings(resolvedAgentDir, deps); const cfg = getNotificationConfig(settings); if (!isProviderEffectivelyEnabled(cfg, "telegram") || !isTelegramComplete(cfg)) return; + const installationHostId = await (deps.loadInstallationHostId ?? loadInstallationHostId)(); + const topicRegistryAuthority = new FilesystemTopicRegistryCasAuthority( + path.join(daemonPaths(resolvedAgentDir).dir, "telegram-topics.json"), + { installationHostId }, + ); const Daemon: TelegramDaemonConstructor = deps.DaemonImpl ?? TelegramNotificationDaemon; const readState = deps.readDaemonState ?? readDaemonState; const daemon = new Daemon({ @@ -247,6 +256,8 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD btw: cfg.btw, pid: deps.processPid ?? process.pid, control: createDaemonControlHooks(settings as Settings), + topicRegistryAuthority, + installationHostId, }); // Signals are a process concern: install them at the daemon-internal boundary, // not inside the embeddable daemon class. SIGTERM is the reload wakeup path. diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts index c57e1cb4a4..e410d090e1 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts @@ -55,6 +55,10 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3; * ownership, settlement, and descendant cleanup authority. Generation 38 also * adds durable provider-intent admission without changing owner, reclaim, * signal, or spawn authority. + * Generation 41 applies first-class provider-settings admission to Telegram + * lifecycle controls, plus cross-host topic-registry CAS convergence, host-and-epoch + * archive fencing, retained topic history, user-topic adoption provenance, and + * exact versionless shared-state upgrades with quarantined source snapshots. * Generation 42 applies first-class provider-settings admission to Telegram * lifecycle controls. Generation 43 applies identity-bound exact replacement * cleanup shared by managed-session and daemon filesystem authority. Generation @@ -68,12 +72,11 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3; * Generation 49 drains every admitted session-message handler before final * durable persistence and ownership release. */ -export const DAEMON_GENERATION = 49; +export const DAEMON_GENERATION = 50; /** - * Serving-compatibility boundary for daemon lifecycle requests. Epoch 1 covers - * all builds published before this field existed; epoch 2 covered generation 29; - * epoch 3 covered generation 30; bump this to force serving convergence and - * reload of compatible live predecessors. + * Serving-compatibility boundary for daemon lifecycle requests. Epoch 5 + * requires the complete generation-36 topic authority contract, so older + * epoch-4 daemons cannot keep serving across an upgrade. */ -export const SERVING_EPOCH = 4; +export const SERVING_EPOCH = 5; diff --git a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts index 45d393c05e..af794b9141 100644 --- a/packages/coding-agent/src/sdk/bus/telegram-daemon.ts +++ b/packages/coding-agent/src/sdk/bus/telegram-daemon.ts @@ -4,6 +4,7 @@ import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { exactReplacePath, type NativeExactFileIdentity, type NativeExactUnlinkResult } from "@gajae-code/natives"; import { logger } from "@gajae-code/utils"; import { withFileLock } from "../../config/file-lock"; import type { Settings } from "../../config/settings"; @@ -107,7 +108,13 @@ import { } from "./telegram-reference"; import { decideThreadedInbound, type InboundAttachment } from "./threaded-inbound"; import { renderThreadedFrame, supportsTelegramPhotoUpload, type ThreadedSend } from "./threaded-render"; -import { type TopicEndpointBinding, TopicRegistry, type TopicRegistryState } from "./topic-registry"; +import { + parseTopicRegistryState, + type TopicEndpointBinding, + TopicRegistry, + type TopicRegistryCasAuthority, + type TopicRegistryState, +} from "./topic-registry"; export type EnsureDaemonResult = "owner_spawned" | "attached" | "disabled" | "blocked"; /** Detailed result for orchestration that must distinguish a #2028 handoff from a fresh spawn. */ @@ -146,6 +153,15 @@ export interface DaemonState { servingEpoch?: number; stoppedAt?: number; } +interface ExactFileStat { + dev: bigint; + ino: bigint; + nlink: bigint; + size: bigint; + mtimeNs: bigint; + isFile(): boolean; +} + export interface TelegramDaemonFs { mkdir(path: string, opts?: fs.MakeDirectoryOptions): Promise; readFile(path: string, encoding: BufferEncoding): Promise; @@ -155,6 +171,9 @@ export interface TelegramDaemonFs { open(path: string, flags: string, mode?: number): Promise<{ sync?: () => Promise; close(): Promise }>; readdir(path: string): Promise; chmod(path: string, mode: number): Promise; + /** Crash-atomic persistence seams. Implementations without them fail closed. */ + fsyncFile?(path: string): Promise; + fsyncDirectory?(path: string): Promise; stat?(path: string): Promise<{ mtimeMs: number; size?: number; @@ -163,8 +182,9 @@ export interface TelegramDaemonFs { /** Hard-link count; required to prove a staging temp has no second name. */ nlink?: number; ctimeMs?: number; - isDirectory?: () => boolean; + isDirectory?(): boolean; }>; + lstat?(path: string, opts: { bigint: true }): Promise; readEndpointFile?(path: string): Promise; exactUnlink?( path: string, @@ -233,6 +253,22 @@ function negotiateToolActivityCapability( const nodeFs: TelegramDaemonFs = { ...(fs.promises as unknown as TelegramDaemonFs), + fsyncFile: async file => { + const handle = await fs.promises.open(file, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }, + fsyncDirectory: async directory => { + const handle = await fs.promises.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }, readEndpointFile: readNotificationEndpointFile, exactUnlink: async (file, identity, quarantineName) => exactUnlinkNotificationFile( @@ -479,12 +515,15 @@ function endpointGenerationKey(url: string, token: string): string { function topicRenameApplied(response: unknown): boolean { return !!response && typeof response === "object" && (response as { ok?: unknown }).ok === true; } -function topicDeleteSettled(response: unknown): boolean { +function topicArchiveSettled(response: unknown): boolean { if (!response || typeof response !== "object") return false; - const result = response as { ok?: unknown; description?: unknown }; - if (result.ok === true) return true; - if (typeof result.description !== "string") return false; - return /(?:TOPIC_ID_INVALID|message thread not found)/i.test(result.description); + const result = response as { ok?: unknown; result?: unknown; error_code?: unknown; description?: unknown }; + if (result.ok === true && result.result === true) return true; + if (result.ok !== false || typeof result.description !== "string") return false; + const description = result.description.trim(); + return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test( + description, + ); } /** @@ -672,6 +711,347 @@ function isUnsupportedTelegramDirectoryBarrier(error: unknown): boolean { return code === "EINVAL" || code === "EISDIR" || code === "ENOTSUP" || code === "EPERM"; } +export class TopicRegistryDurabilityUnavailableError extends Error { + readonly code = "durability_unavailable"; + + constructor(cause: unknown) { + super("topic registry durability is unavailable", { cause }); + this.name = "TopicRegistryDurabilityUnavailableError"; + } +} + +type TopicRegistryExactReplace = ( + sourcePath: string, + destinationPath: string, + expectedSource: NativeExactFileIdentity, + expectedDestination: NativeExactFileIdentity, +) => NativeExactUnlinkResult; + +function sameExactFileStat(left: ExactFileStat, right: ExactFileStat): boolean { + return ( + left.dev === right.dev && + left.ino === right.ino && + left.nlink === right.nlink && + left.size === right.size && + left.mtimeNs === right.mtimeNs + ); +} + +async function captureExactFileSnapshot( + fsImpl: TelegramDaemonFs, + file: string, +): Promise<{ bytes: string; identity: NativeExactFileIdentity }> { + if (!fsImpl.lstat) + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry persistence requires no-follow file identity"), + ); + const before = await fsImpl.lstat(file, { bigint: true }); + const bytes = await fsImpl.readFile(file, "utf8"); + const after = await fsImpl.lstat(file, { bigint: true }); + const parentStat = await fsImpl.lstat(path.dirname(file), { bigint: true }); + if (!before.isFile() || !after.isFile() || before.nlink !== 1n || !sameExactFileStat(before, after)) + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry persistence requires a stable single-link regular file"), + ); + return { + bytes, + identity: { + dev: after.dev, + ino: after.ino, + nlink: after.nlink, + parentDev: parentStat.dev, + parentIno: parentStat.ino, + size: after.size, + mtimeNs: after.mtimeNs, + sha256: crypto.createHash("sha256").update(bytes).digest("hex"), + }, + }; +} +async function captureTopicRegistryDestination( + fsImpl: TelegramDaemonFs, + file: string, + platform: NodeJS.Platform, +): Promise { + if (platform !== "win32") return undefined; + try { + return (await captureExactFileSnapshot(fsImpl, file)).identity; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +/** Publish topic authority only after its staged bytes and replacement are durable. */ +export async function writeTopicRegistryAtomic( + fsImpl: TelegramDaemonFs, + file: string, + data: unknown, + platform: NodeJS.Platform = process.platform, + exactReplace: TopicRegistryExactReplace = exactReplacePath, + expectedDestination?: NativeExactFileIdentity, +): Promise { + if (!fsImpl.fsyncFile) + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry persistence requires file fsync crash-atomicity"), + ); + if (platform !== "win32" && !fsImpl.fsyncDirectory) + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry persistence requires directory fsync crash-atomicity"), + ); + const parent = path.dirname(file); + const serialized = `${JSON.stringify(data, null, 2)}\n`; + let destinationExists = true; + try { + await fsImpl.readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + destinationExists = false; + } + if (!destinationExists && expectedDestination) + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry destination disappeared after generation validation"), + ); + if (!destinationExists) { + try { + await fsImpl.writeFile(file, serialized, { mode: 0o600, flag: "wx" }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") + throw new TopicRegistryDurabilityUnavailableError( + new Error("topic registry appeared during exclusive creation"), + ); + throw error; + } + await fsImpl.chmod(file, 0o600).catch(() => undefined); + await fsImpl.fsyncFile(file); + if (platform !== "win32") await fsImpl.fsyncDirectory!(parent); + const written = await fsImpl.readFile(file, "utf8"); + if (written !== serialized) + throw new TopicRegistryDurabilityUnavailableError( + new Error("exclusive topic registry creation verification failed"), + ); + return; + } + + const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; + try { + await fsImpl.writeFile(tmp, serialized, { mode: 0o600, flag: "wx" }); + await fsImpl.chmod(tmp, 0o600).catch(() => undefined); + await fsImpl.fsyncFile(tmp); + if (platform === "win32") { + if (typeof exactReplace !== "function") + throw new TopicRegistryDurabilityUnavailableError( + new Error("native Windows exact replacement is unavailable"), + ); + if (!expectedDestination) + throw new TopicRegistryDurabilityUnavailableError( + new Error("native Windows replacement requires the validated destination identity"), + ); + const expectedSource = (await captureExactFileSnapshot(fsImpl, tmp)).identity; + const outcome = exactReplace(tmp, file, expectedSource, expectedDestination); + if (!outcome.ok) + throw new TopicRegistryDurabilityUnavailableError( + new Error(`native Windows topic replacement failed: ${outcome.code ?? "unknown"}`), + ); + } else { + await fsImpl.rename(tmp, file); + await fsImpl.fsyncDirectory!(parent); + } + const written = await fsImpl.readFile(file, "utf8"); + if (written !== serialized) + throw new TopicRegistryDurabilityUnavailableError(new Error("topic registry replacement verification failed")); + } catch (error) { + await fsImpl.unlink(tmp).catch(() => undefined); + throw error; + } +} +/** + * Shared-volume topic authority backed by the existing cross-process file lock. + * A missing authority is the only valid bootstrap state; malformed and future + * snapshots are never interpreted as empty state. + */ +export class FilesystemTopicRegistryCasAuthority implements TopicRegistryCasAuthority { + private readonly fsImpl: TelegramDaemonFs; + private readonly platform: NodeJS.Platform; + private readonly exactReplace: TopicRegistryExactReplace; + private readonly installationHostId: string; + + constructor( + private readonly file: string, + input: { + installationHostId: string; + fs?: TelegramDaemonFs; + platform?: NodeJS.Platform; + exactReplace?: TopicRegistryExactReplace; + }, + ) { + if (!input.installationHostId) throw new Error("installationHostId must be non-empty"); + this.installationHostId = input.installationHostId; + this.fsImpl = input.fs ?? nodeFs; + this.platform = input.platform ?? process.platform; + this.exactReplace = input.exactReplace ?? exactReplacePath; + } + + async read(): Promise { + return await withFileLock(this.file, async () => await this.readLocked(), { + staleMs: 10_000, + ownerHostId: this.installationHostId, + }); + } + + async compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise { + if (!Number.isSafeInteger(expectedGeneration) || expectedGeneration < 0) + throw new Error("invalid expected topic registry generation"); + if ( + next.version !== 2 || + next.registryGeneration !== expectedGeneration + 1 || + parseTopicRegistryState(next) === undefined + ) + throw new Error("invalid next topic registry generation"); + return await withFileLock( + this.file, + async () => { + const { state: current, legacyRaw, expectedDestination } = await this.#readLockedWithLegacy(); + if (current.registryGeneration !== expectedGeneration) return false; + await ensureDir(this.fsImpl, path.dirname(this.file)); + if (legacyRaw !== undefined) await this.#quarantineLegacyLocked(legacyRaw); + await writeTopicRegistryAtomic( + this.fsImpl, + this.file, + next, + this.platform, + this.exactReplace, + expectedDestination, + ); + return true; + }, + { staleMs: 10_000, ownerHostId: this.installationHostId }, + ); + } + + private async readLocked(): Promise { + return (await this.#readLockedWithLegacy()).state; + } + + async #readLockedWithLegacy(): Promise<{ + state: TopicRegistryState; + legacyRaw?: unknown; + expectedDestination?: NativeExactFileIdentity; + }> { + let raw: unknown; + let expectedDestination: NativeExactFileIdentity | undefined; + try { + if (this.platform === "win32") { + const snapshot = await captureExactFileSnapshot(this.fsImpl, this.file); + raw = JSON.parse(snapshot.bytes); + expectedDestination = snapshot.identity; + } else { + raw = JSON.parse(await this.fsImpl.readFile(this.file, "utf8")); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return { state: { version: 2, registryGeneration: 0, topics: {} } }; + throw new Error("shared topic authority is malformed or unavailable"); + } + const legacyRaw = + raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version") + ? raw + : undefined; + const state = parseTopicRegistryState(raw); + const generation = state?.registryGeneration; + if (state?.version !== 2 || generation === undefined || !Number.isSafeInteger(generation) || generation < 0) + throw new Error("shared topic authority is malformed or unsupported"); + return { + state: { ...state, registryGeneration: generation }, + ...(legacyRaw === undefined ? {} : { legacyRaw }), + ...(expectedDestination === undefined ? {} : { expectedDestination }), + }; + } + + async #quarantineLegacyLocked(raw: unknown): Promise { + const digest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex"); + const quarantinePath = `${this.file}.legacy-quarantine.${digest}.json`; + try { + await this.fsImpl.readFile(quarantinePath, "utf8"); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + throw new Error("shared topic authority is malformed or unavailable"); + } + await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw, this.platform, this.exactReplace); + } +} + +export interface MachineIdentityDeps { + platform?: NodeJS.Platform; + readFile?: (file: string) => Promise; + runCommand?: (command: string, args: readonly string[]) => { exitCode: number; stdout: Uint8Array }; +} + +function normalizedMachineIdentity(value: string, pattern: RegExp): string | undefined { + const normalized = value.trim().toLowerCase(); + if (!pattern.test(normalized) || /^0+$/.test(normalized.replace(/-/g, ""))) return undefined; + return normalized; +} + +/** @internal */ +export function parseWindowsMachineGuid(output: string): string | undefined { + const match = /^\s*MachineGuid\s+REG_\w+\s+(\S+)\s*$/im.exec(output); + return match + ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + : undefined; +} + +/** @internal */ +export function parseMacPlatformUuid(output: string): string | undefined { + const match = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output); + return match + ? normalizedMachineIdentity(match[1], /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + : undefined; +} + +function hashMachineIdentity(rawId: string): string { + return crypto + .createHash("sha256") + .update("gajae-code:telegram-daemon:machine-identity:v1\0") + .update(rawId) + .digest("hex"); +} + +/** Loads a verified machine-local identity without persisting the underlying machine ID. */ +export async function loadInstallationHostId(deps: MachineIdentityDeps = {}): Promise { + const platform = deps.platform ?? process.platform; + const readFile = deps.readFile ?? (async (file: string) => await fs.promises.readFile(file, "utf8")); + const runCommand = + deps.runCommand ?? + ((command: string, args: readonly string[]) => + Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" })); + + let rawId: string | undefined; + if (platform === "linux") { + for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const value = normalizedMachineIdentity(await readFile(file), /^[0-9a-f]{32}$/); + if (!value) throw new Error("machine-local identity is unavailable or malformed"); + rawId = value; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + } else if (platform === "win32") { + const result = runCommand("reg", ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"]); + if (result.exitCode === 0) rawId = parseWindowsMachineGuid(new TextDecoder().decode(result.stdout)); + } else if (platform === "darwin") { + const result = runCommand("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"]); + if (result.exitCode === 0) rawId = parseMacPlatformUuid(new TextDecoder().decode(result.stdout)); + } else { + throw new Error(`machine-local identity is unsupported on ${platform}`); + } + + if (!rawId) throw new Error("machine-local identity is unavailable or malformed"); + return hashMachineIdentity(rawId); +} + function validDaemonPid(pid: unknown): pid is number { return typeof pid === "number" && Number.isSafeInteger(pid) && pid > 0; } @@ -2260,7 +2640,8 @@ export function isCurrentCompatibleOwner(input: { state?.ownershipPhase === "ready" && typeof state.acquisitionId === "string" && state.acquisitionId.length > 0 && - (state.servingEpoch === undefined ? 1 : state.servingEpoch) >= SERVING_EPOCH, + state.generation === DAEMON_GENERATION && + state.servingEpoch === SERVING_EPOCH, ); } @@ -3198,6 +3579,8 @@ export interface TelegramSpawnOwnerInput { roots?: string[]; tokenFingerprint: string; chatId: string; + /** Ephemeral outbound-only validation destination for this owner launch. */ + validationTestSupergroupChatId?: string; } export interface TelegramSpawnAcquisition { @@ -3225,7 +3608,12 @@ export type TelegramSpawnOwnerResult = * Source mode prepends the entry script so the respawn loads edited source; * a compiled binary self-spawns its own subcommand directly. */ -export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId: string; agentDir: string }): { +export function buildTelegramDaemonSpawnArgs(input: { + execPath?: string; + ownerId: string; + agentDir: string; + validationTestSupergroupChatId?: string; +}): { command: string; args: string[]; runtime: DaemonRuntimeInfo; @@ -3239,6 +3627,9 @@ export function buildTelegramDaemonSpawnArgs(input: { execPath?: string; ownerId input.ownerId, "--agent-dir", input.agentDir, + ...(input.validationTestSupergroupChatId !== undefined + ? ["--validation-test-supergroup-chat-id", input.validationTestSupergroupChatId] + : []), ]; const runtime: DaemonRuntimeInfo = { mode: rt.mode, @@ -3314,6 +3705,7 @@ export async function spawnTelegramDaemonOwner( execPath, ownerId: provisionalAcquisition.ownerId, agentDir, + validationTestSupergroupChatId: input.validationTestSupergroupChatId, }); const spawnImpl = deps.spawn ?? defaultDaemonSpawn; const child = spawnImpl(command, args, { @@ -3495,13 +3887,17 @@ async function ensureTelegramDaemonRunningDetailedOnce( // unproven cooperative handoff into destructive cleanup or a replacement spawn. if ((deps.platform ?? process.platform) === "win32") { const parentState: unknown = await readDaemonState(input.settings, deps.fs); - if (isParentDaemonState(parentState) && (deps.pidAlive ?? defaultPidAlive)(parentState.pid)) + if ( + isParentDaemonState(parentState) && + (deps.pidAlive ?? defaultPidAlive)(parentState.pid) && + (!hasSafeDaemonStateShape(parentState) || ownerProvenanceMatches(parentState, deps.pidIncarnation)) + ) return "blocked_identity"; } // Windows can retain dead launcher metadata without an ownership lock; reclaim // its dead discovery records before the replacement can publish a new owner. if ((deps.platform ?? process.platform) === "win32" && !deps.fs) { - const preflight = await reclaimDeadDaemonOwner({ + let preflight = await reclaimDeadDaemonOwner({ settings: input.settings, endpointDir: path.join(root, "sdk"), fs: deps.fs, @@ -3509,6 +3905,28 @@ async function ensureTelegramDaemonRunningDetailedOnce( pidAlive: deps.pidAlive, pidIncarnation: deps.pidIncarnation, }); + // A just-reused PID can be reported alive for the first probe. Recheck once + // before acquisition only when stale discovery records need fenced cleanup. + // reclaimDeadDaemonOwner still requires exact state, lock, incarnation, + // transition-lock, and endpoint-identity proofs before deleting anything. + const endpointNames = + preflight.reason === "not-confirmed-dead" + ? await (deps.fs ?? nodeFs).readdir(path.join(root, "sdk")).catch(() => undefined) + : undefined; + if ( + !preflight.recovered && + preflight.reason === "not-confirmed-dead" && + endpointNames?.some(name => name.endsWith(".json")) + ) { + preflight = await reclaimDeadDaemonOwner({ + settings: input.settings, + endpointDir: path.join(root, "sdk"), + fs: deps.fs, + now: deps.now, + pidAlive: deps.pidAlive, + pidIncarnation: deps.pidIncarnation, + }); + } if (!preflight.recovered && preflight.reason !== "not-confirmed-dead") { logger.warn( `notifications: startup recovery unsafe (${preflight.reason}); run \`gjc notify recovery\` for diagnostics`, @@ -3925,7 +4343,7 @@ export class TelegramBotTransport implements BotApi { } } -type PairedChatPrivacy = "private" | "non-private" | "indeterminate"; +type PairedChatPrivacy = "private" | "validation-forum" | "non-private" | "indeterminate"; export type TelegramUpdateOutcome = "consumed" | "retry"; @@ -4141,6 +4559,12 @@ export interface TelegramDaemonOptions { ownerId: string; botToken: string; chatId: string; + /** + * Exact ephemeral forum destination supplied to the daemon-internal validation + * command. Production ownership, pairing, and durable topic authority remain + * bound to `chatId`. + */ + validationTestSupergroupChatId?: string; apiBase?: string; fetchImpl?: typeof fetch; fs?: TelegramDaemonFs; @@ -4192,6 +4616,13 @@ export interface TelegramDaemonOptions { * built-in `{repo}/{branch} - {title}` composition and its fallbacks. */ topics?: { nameTemplate?: string }; + /** + * Optional compare-and-set store for installations that share topic state + * across hosts. When configured, every publication is fenced by it. + */ + topicRegistryAuthority?: TopicRegistryCasAuthority; + /** Stable host-local identity. Required whenever a shared authority is configured. */ + installationHostId?: string; } interface StagedCallbackActivation { @@ -4309,7 +4740,8 @@ interface PendingThreadedFrame { send: ThreadedSend; msg: Record; logicalSessionId: string; - socketLease: { session: SessionSocket; token: number; logicalSessionId: string }; + session: SessionSocket; + socketLease?: { session: SessionSocket; token: number; logicalSessionId: string }; toolActivity?: ToolActivityOwner; } @@ -4573,6 +5005,8 @@ export class TelegramNotificationDaemon { private readonly botApi: BotApi; private readonly effects = new TelegramEffectSupervisor(); private readonly topics = new TopicRegistry(); + /** Stable host-local identity; never persisted in shared topic authority. */ + private installationHostId: string; /** * Telegram may accept createForumTopic while returning an unusable success * payload. Remember that ambiguity per endpoint so later frames cannot repeat @@ -4587,6 +5021,11 @@ export class TelegramNotificationDaemon { private recoveryBindingClaimQueue: Promise = Promise.resolve(); /** Durable compensation fences retry under supervision until persistence succeeds. */ private readonly compensationFenceRetries = new Map>(); + /** All archive paths for one session share one durable fence and remote dispatch. */ + private readonly archiveFlights = new Map< + string, + Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> + >(); /** Daemon edit attempts that can race an accepted user service message. */ private readonly daemonRenameAttempts = new Map(); @@ -4627,8 +5066,8 @@ export class TelegramNotificationDaemon { private threadedFallbackNoticeSent = false; /** Sessions whose identity header was already sent flat (Threaded Mode off). */ private readonly flatIdentitySent = new Set(); - /** Cached result of whether the paired chat is a private chat (flat-fallback gate). */ - private pairedChatPrivate: boolean | undefined; + /** Cached delivery boundary for the private owner chat or validation forum. */ + private pairedChatPrivacy: PairedChatPrivacy | undefined; /** Bot username from getMe, cached once at owner startup for group/forum command targeting. */ private botUsername: string | undefined; /** Sessions whose agent loop is currently busy (drives the typing indicator). */ @@ -5061,7 +5500,8 @@ export class TelegramNotificationDaemon { const outcome = classifyBotApiCallOutcome(undefined, true); return { response: undefined, outcome }; } - const response = await this.effects.call(rawBotApi, method, body, callOpts); + const outboundBody = this.validationTopicDestination(method, body); + const response = await this.effects.call(rawBotApi, method, outboundBody, callOpts); const outcome = classifyBotApiCallOutcome(response); if (outcome.kind === "retryable") this.botCooldownUntil = Math.max( @@ -5070,6 +5510,20 @@ export class TelegramNotificationDaemon { ); return { response, outcome }; } + private validationTopicDestination(method: string, body: unknown): unknown { + if ( + !this.validationMode() || + method === "getChat" || + !body || + typeof body !== "object" || + String((body as { chat_id?: unknown }).chat_id) !== String(this.opts.chatId) + ) + return body; + return { + ...(body as Record), + chat_id: this.opts.validationTestSupergroupChatId!, + }; + } private readonly callBotApiClassified: ( method: string, @@ -5090,6 +5544,9 @@ export class TelegramNotificationDaemon { constructor(private readonly opts: TelegramDaemonOptions) { this.fsImpl = opts.fs ?? nodeFs; this.replyStore = new ReplySentStore({ agentDir: opts.settings.getAgentDir(), fs: opts.fs }); + if (opts.topicRegistryAuthority && !opts.installationHostId) + throw new Error("shared topic authority requires a host-local installation identity"); + this.installationHostId = opts.installationHostId ?? crypto.randomUUID(); this.aliasTable = createAliasTable(); this.#adoptionIntentTtlMs = opts.adoptionIntentTtlMs ?? DEFAULT_ADOPTION_INTENT_TTL_MS; const adoptionFs: AdoptionIntentFs = { @@ -5199,7 +5656,6 @@ export class TelegramNotificationDaemon { if (!socketLease) return; const topicLease = this.topicAuthorityLeaseFromRegistry(logicalSessionId); if ( - this.topics.get(logicalSessionId)?.authorityState === "delete_pending" || this.topics.get(logicalSessionId)?.bindingMalformed || (mode === "recovery" && (!topicLease || msg.sessionId !== logicalSessionId)) ) { @@ -5358,40 +5814,49 @@ export class TelegramNotificationDaemon { if (socketLease && !this.#leaseTokenAllows(socketLease)) return; const closedBinding = this.#endpointBinding(session); - const closeTopicAuthority = this.topics.captureDeleteAuthority(logicalSessionId); + const closedBindingMatches = () => { + const current = this.closedEndpointKeys.get(session.sessionId); + return ( + current?.chatId === closedBinding.chatId && + current.endpointKey === closedBinding.endpointKey && + current.endpointDigest === closedBinding.endpointDigest && + current.endpointGeneration === closedBinding.endpointGeneration + ); + }; + const closeTopicAuthority = this.topics.captureArchiveAuthority(logicalSessionId); const previousClosedBinding = this.closedEndpointKeys.get(session.sessionId); await this.#persistTopicMutation( () => { this.closedEndpointKeys.set(session.sessionId, closedBinding); - this.topics.beginDelete(logicalSessionId); + this.topics.beginArchive(logicalSessionId, this.installationHostId, this.runtime.now()); }, () => { - this.topics.restoreDeleteAuthority(closeTopicAuthority); + this.topics.restoreArchiveAuthority(closeTopicAuthority); if (previousClosedBinding === undefined) this.closedEndpointKeys.delete(session.sessionId); - else if (this.closedEndpointKeys.get(session.sessionId) === closedBinding) + else if (closedBindingMatches()) this.closedEndpointKeys.set(session.sessionId, previousClosedBinding); }, ); if ( - socketLease && - (this.sessions.get(session.sessionId) !== session || - session.recoveryLease?.token !== socketLease.token || - session.recoveryLease.state !== "authorized" || - this.logicalSessionOwners.get(logicalSessionId) !== session) + this.sessions.get(session.sessionId) !== session || + (socketLease && + (session.recoveryLease?.token !== socketLease.token || + session.recoveryLease.state !== "authorized" || + this.logicalSessionOwners.get(logicalSessionId) !== session)) ) { // A replacement won after the close fence committed. Restore the exact // pre-close authority and remove the predecessor tombstone together. const restoreCloseAuthority = (): Promise => this.#persistTopicMutation( () => { - const restored = this.topics.restoreDeleteAuthority(closeTopicAuthority); - if (!restored) throw new Error("close authority changed before compensation"); - this.closedEndpointKeys.delete(session.sessionId); + this.topics.restoreArchiveAuthority(closeTopicAuthority); + if (closedBindingMatches()) this.closedEndpointKeys.delete(session.sessionId); return true; }, () => { - this.topics.restoreDeleteFence(closeTopicAuthority); - this.closedEndpointKeys.set(session.sessionId, closedBinding); + this.topics.restoreArchiveFence(closeTopicAuthority); + if (this.sessions.get(session.sessionId) === session) + this.closedEndpointKeys.set(session.sessionId, closedBinding); }, ); try { @@ -5401,7 +5866,7 @@ export class TelegramNotificationDaemon { } return; } - const deleteOutcome = await this.deleteTopic(logicalSessionId, socketLease, true); + const deleteOutcome = await this.archiveTopic(logicalSessionId, socketLease, true); if ( deleteOutcome === "pre_dispatch_cancelled" && socketLease && @@ -5409,12 +5874,12 @@ export class TelegramNotificationDaemon { ) { await this.#persistTopicMutation( () => { - if (!this.topics.restoreDeleteAuthority(closeTopicAuthority)) + if (!this.topics.restoreArchiveAuthority(closeTopicAuthority)) throw new Error("close authority changed before compensation"); this.closedEndpointKeys.delete(session.sessionId); }, () => { - this.topics.restoreDeleteFence(closeTopicAuthority); + this.topics.restoreArchiveFence(closeTopicAuthority); this.closedEndpointKeys.set(session.sessionId, closedBinding); }, ); @@ -5427,6 +5892,7 @@ export class TelegramNotificationDaemon { } async loadAliases(): Promise { + if (this.validationMode()) return; const raw = await readJson(this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).aliases); if (!raw) return; const persisted = raw as { revokedRoutes?: unknown }; @@ -5440,6 +5906,7 @@ export class TelegramNotificationDaemon { } persistAliases(): Promise { + if (this.validationMode()) return Promise.resolve(); const pending = this.aliasPersistenceQueue.then(async () => { const paths = daemonPaths(this.opts.settings.getAgentDir()); await ensureDir(this.fsImpl, paths.dir); @@ -5458,6 +5925,7 @@ export class TelegramNotificationDaemon { } async loadSeenUpdateIds(): Promise { + if (this.validationMode()) return; const raw = await readJson<{ updateIds?: unknown }>( this.fsImpl, daemonPaths(this.opts.settings.getAgentDir()).seenUpdates, @@ -5473,6 +5941,7 @@ export class TelegramNotificationDaemon { } async persistSeenUpdateIds(): Promise { + if (this.validationMode()) return; const paths = daemonPaths(this.opts.settings.getAgentDir()); await ensureDir(this.fsImpl, paths.dir); await writeJsonAtomic(this.fsImpl, paths.seenUpdates, { @@ -5502,6 +5971,12 @@ export class TelegramNotificationDaemon { } } private async reserveSeenUpdateId(updateId: number): Promise { + if (this.validationMode()) { + if (this.dispatchState.seenUpdateIds.has(updateId)) return false; + this.dispatchState.seenUpdateIds.add(updateId); + this.pruneSeenUpdateIds(); + return true; + } if (!Number.isSafeInteger(updateId) || updateId < 0) return false; const candidate = new Set(this.dispatchState.seenUpdateIds); candidate.add(updateId); @@ -5523,6 +5998,10 @@ export class TelegramNotificationDaemon { } private async releaseSeenUpdateId(updateId: number): Promise { + if (this.validationMode()) { + this.dispatchState.seenUpdateIds.delete(updateId); + return; + } if (!this.dispatchState.seenUpdateIds.has(updateId)) return; const candidate = new Set(this.dispatchState.seenUpdateIds); candidate.delete(updateId); @@ -5608,28 +6087,31 @@ export class TelegramNotificationDaemon { } catch {} } } - if (permanentlyMissingRoots.length > 0) { + if (!this.validationMode()) { + if (permanentlyMissingRoots.length > 0) { + try { + await pruneMissingNotificationRoots({ + settings: this.opts.settings, + fs: this.fsImpl, + candidates: permanentlyMissingRoots, + }); + } catch (error) { + logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`); + } + } + // Best-effort periodic reap of retained exact-unlink quarantines (#2956). try { - await pruneMissingNotificationRoots({ + await reapStaleNotificationArtifacts({ settings: this.opts.settings, fs: this.fsImpl, - candidates: permanentlyMissingRoots, + now: this.opts.now, + pidAlive: this.opts.pidAlive, }); } catch (error) { - logger.warn(`notifications: dead-root prune failed: ${sanitizeDiagnostic(String(error))}`); + logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`); } } - // Best-effort periodic reap of retained exact-unlink quarantines (#2956). - try { - await reapStaleNotificationArtifacts({ - settings: this.opts.settings, - fs: this.fsImpl, - now: this.opts.now, - pidAlive: this.opts.pidAlive, - }); - } catch (error) { - logger.warn(`notifications: leak-artifact reap failed: ${sanitizeDiagnostic(String(error))}`); - } + if (allRootsReadable) { for (const sessionId of this.topics.sessionIds()) { const owner = this.logicalSessionOwners.get(sessionId); @@ -5682,7 +6164,7 @@ export class TelegramNotificationDaemon { replayQueue: [], }; this.sessions.set(sessionId, session); - if (this.topics.get(sessionId)) this.preservedInitiatorTopics.add(sessionId); + if (this.topics.get(sessionId)?.authorityState === "active") this.preservedInitiatorTopics.add(sessionId); // Bidirectional capability advertisement: announce client_ping_pong once the // socket is open. Sent on "open" only — a real WHATWG WebSocket cannot send @@ -5727,7 +6209,9 @@ export class TelegramNotificationDaemon { void (async () => { if (this.#logicalSessionId(session) !== sessionId) return; const topic = this.topics.get(sessionId); - if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return; + if (!topic || topic.bindingMalformed) return; + if (topic.authorityState === "disconnect_grace" && !(await this.#renewTopicLease(sessionId))) return; + if (topic.authorityState !== "active") return; const topicLease = this.topicAuthorityLeaseFromRegistry(sessionId); if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(sessionId, topicLease); })().catch(err => @@ -5773,6 +6257,11 @@ export class TelegramNotificationDaemon { this.dropSession(session, "liveness_timeout"); return; } + const logicalSessionId = this.#logicalSessionId(session); + if (session.logicalSessionIdTrusted) + void this.#renewTopicLease(logicalSessionId).then(renewed => { + if (!renewed) this.dropSession(session, "topic_lease_lost"); + }); if (session.ws.readyState === WebSocket.OPEN) { const nonce = `${session.sessionId}:${t}:${Math.random().toString(36).slice(2)}`; session.awaitingNonce = nonce; @@ -5946,6 +6435,7 @@ export class TelegramNotificationDaemon { } else { void this.#terminalizeBtwTurnsForSession(session).catch(() => undefined); } + if (isCurrentSession && session.logicalSessionIdTrusted) this.#releaseTopicLease(this.#logicalSessionId(session)); if (isCurrentSession || reason === "session_closed") { this.deleteMessageRoutes(session.sessionId); } @@ -6374,8 +6864,10 @@ export class TelegramNotificationDaemon { lease.binding.endpointDigest === session.endpointDigest && lease.binding.endpointGeneration === session.hostGeneration && (!record || - (record.authorityState !== "delete_pending" && + (record.authorityState === "active" && !record.bindingMalformed && + record.leaseOwner === this.installationHostId && + (record.leaseExpiresAt ?? 0) > this.runtime.now() && record.chatId === lease.binding.chatId && record.endpointKey === lease.binding.endpointKey && record.endpointDigest === lease.binding.endpointDigest && @@ -6459,6 +6951,71 @@ export class TelegramNotificationDaemon { ? undefined : { session, token: 0, logicalSessionId }; } + async #renewTopicLease(sessionId: string): Promise { + const record = this.topics.get(sessionId); + if (!record) return true; + const previous = { + leaseOwner: record.leaseOwner, + leaseHeartbeatAt: record.leaseHeartbeatAt, + leaseExpiresAt: record.leaseExpiresAt, + authorityState: record.authorityState, + orphanedAt: record.orphanedAt, + disconnectGraceExpiresAt: record.disconnectGraceExpiresAt, + }; + return this.#persistTopicMutation( + () => + this.topics.acquireLease( + sessionId, + this.installationHostId, + this.runtime.now(), + HEARTBEAT_TTL_MS, + ORPHAN_TOPIC_GRACE_MS, + ), + () => { + const current = this.topics.get(sessionId); + if (!current) return; + Object.assign(current, previous); + if (previous.leaseOwner === undefined) delete current.leaseOwner; + if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt; + if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt; + if (previous.orphanedAt === undefined) delete current.orphanedAt; + if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt; + }, + ); + } + #releaseTopicLease(sessionId: string): void { + const record = this.topics.get(sessionId); + if (!record) return; + const previous = { + leaseHeartbeatAt: record.leaseHeartbeatAt, + leaseExpiresAt: record.leaseExpiresAt, + authorityState: record.authorityState, + orphanedAt: record.orphanedAt, + disconnectGraceExpiresAt: record.disconnectGraceExpiresAt, + }; + void this.#persistTopicMutation( + () => { + const owner = this.logicalSessionOwners.get(sessionId); + return owner === undefined + ? this.topics.releaseLeaseToGrace( + sessionId, + this.installationHostId, + this.runtime.now(), + ORPHAN_TOPIC_GRACE_MS, + ) + : false; + }, + () => { + const current = this.topics.get(sessionId); + if (!current) return; + Object.assign(current, previous); + if (previous.leaseHeartbeatAt === undefined) delete current.leaseHeartbeatAt; + if (previous.leaseExpiresAt === undefined) delete current.leaseExpiresAt; + if (previous.orphanedAt === undefined) delete current.orphanedAt; + if (previous.disconnectGraceExpiresAt === undefined) delete current.disconnectGraceExpiresAt; + }, + ).catch(() => undefined); + } #authorizeLease(session: SessionSocket, logicalSessionId: string, binding: TopicEndpointBinding): void { const previousSessionId = this.#logicalSessionId(session); @@ -6647,84 +7204,123 @@ export class TelegramNotificationDaemon { const binding = this.#endpointBinding(session); const pendingToken = this.nextSocketLeaseToken++; session.recoveryLease = { state: "pending", logicalSessionId: candidateSessionId, binding, token: pendingToken }; - const claim = await this.#withRecoveryBindingClaim(async () => { - const existing = this.topics.get(candidateSessionId); - const hadDurableTopic = existing !== undefined; - const previousBinding = existing - ? { - chatId: existing.chatId, - endpointKey: existing.endpointKey, - endpointDigest: existing.endpointDigest, - endpointGeneration: existing.endpointGeneration, - endpointIncarnation: existing.endpointIncarnation, - } - : undefined; - const outcome = await this.#persistTopicMutation( - () => - this.topics.get(candidateSessionId) - ? this.topics.bindEndpoint( - candidateSessionId, - binding, - this.#activeEndpointKeysFor(candidateSessionId, session), - allowEndpointRotation, - ) - : "unchanged", - () => { - if (previousBinding) this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding); - }, - ).catch(() => "rejected" as const); - if (outcome === "rejected") { - if (session.recoveryLease?.token === pendingToken) - session.recoveryLease = { - state: "rejected", - logicalSessionId: candidateSessionId, - binding, - token: pendingToken, - }; - return undefined; - } - const endpointAuthority = this.#endpointAuthority(binding, session); - const identitylessAdmissionAllows = - identitylessAdmission === undefined || - (identitylessAdmission === "bootstrap" - ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) || - (endpointAuthority.state === "unique" && + let claim: { previousSessionId: string; hadDurableTopic: boolean } | undefined; + try { + claim = await this.#withRecoveryBindingClaim(async () => { + const existing = this.topics.get(candidateSessionId); + const hadDurableTopic = existing !== undefined; + const previousBinding = existing + ? { + chatId: existing.chatId, + endpointKey: existing.endpointKey, + endpointDigest: existing.endpointDigest, + endpointGeneration: existing.endpointGeneration, + endpointIncarnation: existing.endpointIncarnation, + } + : undefined; + let retiredTopicState: TopicRegistryState | undefined; + const outcome = await this.#persistTopicMutation( + () => { + if (allowEndpointRotation && this.topics.get(candidateSessionId)?.authorityState === "inactive") { + retiredTopicState = this.topics.serialize(); + this.topics.retireInactiveEndpointForSuccessor(candidateSessionId, binding); + } + return this.topics.get(candidateSessionId) + ? this.topics.bindEndpoint( + candidateSessionId, + binding, + this.#activeEndpointKeysFor(candidateSessionId, session), + allowEndpointRotation, + ) + : "unchanged"; + }, + () => { + if (retiredTopicState) this.topics.replace(retiredTopicState); + else if (previousBinding) + this.topics.restoreEndpointBinding(candidateSessionId, binding, previousBinding); + }, + ).catch(() => "rejected" as const); + if (outcome === "rejected") { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return undefined; + } + if (!(await this.#renewTopicLease(candidateSessionId))) { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return undefined; + } + const endpointAuthority = this.#endpointAuthority(binding, session); + const identitylessAdmissionAllows = + identitylessAdmission === undefined || + (identitylessAdmission === "bootstrap" + ? (endpointAuthority.state === "none" && !this.topics.get(candidateSessionId)) || + (endpointAuthority.state === "unique" && + endpointAuthority.sessionId === candidateSessionId && + this.topics.matchesEndpoint(candidateSessionId, binding)) + : endpointAuthority.state === "unique" && endpointAuthority.sessionId === candidateSessionId && - this.topics.matchesEndpoint(candidateSessionId, binding)) - : endpointAuthority.state === "unique" && - endpointAuthority.sessionId === candidateSessionId && - this.topics.matchesEndpoint(candidateSessionId, binding)); - if ( - session.recoveryLease?.token !== pendingToken || - session.recoveryLease.state !== "pending" || - !this.#ownsLiveOpenEndpoint(session, binding) || - !identitylessAdmissionAllows - ) { - if (session.recoveryLease?.token === pendingToken) - session.recoveryLease = { - state: "rejected", - logicalSessionId: candidateSessionId, - binding, - token: pendingToken, - }; - return undefined; - } - const previousSessionId = this.#logicalSessionId(session); - if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true); - if (!this.#ownsLiveOpenEndpoint(session, binding)) { - if (session.recoveryLease?.token === pendingToken) - session.recoveryLease = { - state: "rejected", - logicalSessionId: candidateSessionId, - binding, - token: pendingToken, - }; - return undefined; - } - this.#authorizeLease(session, candidateSessionId, binding); - return { previousSessionId, hadDurableTopic }; - }); - if (!claim) return false; + this.topics.matchesEndpoint(candidateSessionId, binding)); + if ( + session.recoveryLease?.token !== pendingToken || + session.recoveryLease.state !== "pending" || + !this.#ownsLiveOpenEndpoint(session, binding) || + !identitylessAdmissionAllows + ) { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return undefined; + } + const previousSessionId = this.#logicalSessionId(session); + if (previousSessionId !== candidateSessionId) await this.#terminalizeBtwTurnsForSession(session, true); + if (!this.#ownsLiveOpenEndpoint(session, binding)) { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return undefined; + } + this.#authorizeLease(session, candidateSessionId, binding); + return { previousSessionId, hadDurableTopic }; + }); + } catch { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return false; + } + if (!claim) { + if (session.recoveryLease?.token === pendingToken) + session.recoveryLease = { + state: "rejected", + logicalSessionId: candidateSessionId, + binding, + token: pendingToken, + }; + return false; + } const { previousSessionId } = claim; if (preserveTransportTopic && previousSessionId !== candidateSessionId) { this.legacyTopicOwners.set(previousSessionId, session); @@ -6733,7 +7329,7 @@ export class TelegramNotificationDaemon { if (candidateSessionId === session.sessionId) void (async () => { const topic = this.topics.get(candidateSessionId); - if (!topic || topic.authorityState === "delete_pending" || topic.bindingMalformed) return; + if (topic?.authorityState !== "active" || topic.bindingMalformed) return; const topicLease = this.topicAuthorityLeaseFromRegistry(candidateSessionId); if (topicLease?.topicId === topic.topicId) await this.flushPendingThreadedFrames(candidateSessionId, topicLease); @@ -6808,12 +7404,12 @@ export class TelegramNotificationDaemon { const identityKey = this.topicIdentityKey(msg); const remembered = identityKey ? this.topicOwnerByIdentity.get(identityKey) : undefined; const rememberedTopic = remembered ? this.topics.get(remembered) : undefined; - if (remembered && rememberedTopic && rememberedTopic.authorityState !== "delete_pending") return remembered; + if (remembered && rememberedTopic && rememberedTopic.authorityState === "active") return remembered; if (!identityKey) return undefined; const base = this.topicIdentityBase(msg); for (const sessionId of this.topics.sessionIds()) { const topic = this.topics.get(sessionId); - if (topic?.authorityState === "delete_pending") continue; + if (topic?.authorityState !== "active") continue; const nameMatchesLegacyIdentity = base !== undefined && (topic?.name === base || topic?.name?.startsWith(`${base} - `)); if (topic?.identityKey === identityKey || nameMatchesLegacyIdentity) { @@ -7092,21 +7688,29 @@ export class TelegramNotificationDaemon { } private async topicAuthorityLease(sessionId: string): Promise { - if (!(await this.pairedChatIsPrivate())) return undefined; + if (!(await this.pairedChatAllowsTopics())) return undefined; return this.topicAuthorityLeaseFromRegistry(sessionId); } private topicAuthorityLeaseFromRegistry(sessionId: string): TopicAuthorityLease | undefined { const topic = this.topics.get(sessionId); - if (!topic || !this.topics.isActiveUnambiguous(sessionId) || topic.bindingMalformed) return undefined; + if ( + !topic || + !this.topics.isActiveUnambiguous(sessionId) || + topic.bindingMalformed || + topic.leaseOwner !== this.installationHostId || + (topic.leaseExpiresAt ?? 0) <= this.runtime.now() + ) + return undefined; return { sessionId, topicId: topic.topicId, authorityEpoch: topic.authorityEpoch ?? 0 }; } - private topicLeaseIsCurrent(lease: TopicAuthorityLease): boolean { const topic = this.topics.get(lease.sessionId); return ( this.topics.isActiveUnambiguous(lease.sessionId) && !topic?.bindingMalformed && + topic?.leaseOwner === this.installationHostId && + (topic.leaseExpiresAt ?? 0) > this.runtime.now() && topic?.topicId === lease.topicId && (topic.authorityEpoch ?? 0) === lease.authorityEpoch ); @@ -7153,7 +7757,7 @@ export class TelegramNotificationDaemon { ): void { const logicalSessionId = this.#logicalSessionId(session); const socketLease = this.#socketLease(session, logicalSessionId); - if (!socketLease) { + if (!socketLease && !session.logicalSessionIdTrusted) { this.failLegacyToolStart(toolActivity); return; } @@ -7167,6 +7771,7 @@ export class TelegramNotificationDaemon { send, msg, logicalSessionId, + session, socketLease, ...(toolActivity ? { toolActivity } : {}), }); @@ -7182,15 +7787,17 @@ export class TelegramNotificationDaemon { if (!frames || frames.length === 0) return; this.pendingThreadedFrames.delete(sessionId); for (const frame of frames) { + const socketLease = frame.socketLease ?? this.#socketLease(frame.session, sessionId); if ( frame.logicalSessionId !== sessionId || - !this.#leaseTokenAllows(frame.socketLease) || + !socketLease || + !this.#leaseTokenAllows(socketLease) || (frame.msg.type === "tool_activity" && this.opts.toolActivity?.enabled !== true) ) { this.failLegacyToolStart(frame.toolActivity); continue; } - await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, frame.socketLease); + await this.submitThreadedFrame(sessionId, frame.send, topicLease, frame.toolActivity, socketLease); } } @@ -7206,15 +7813,16 @@ export class TelegramNotificationDaemon { session?: SessionSocket, creationLease?: { session: SessionSocket; token: number; logicalSessionId: string }, ): Promise { - if (!(await this.pairedChatIsPrivate())) return undefined; + if (!(await this.pairedChatAllowsTopics())) return undefined; if (session && sessionId === session.sessionId && this.#logicalSessionId(session) !== sessionId) return undefined; const capturedCreationLease = creationLease ?? (session ? this.#socketLease(session, sessionId) : undefined); if (session?.logicalSessionIdTrusted && !capturedCreationLease) return undefined; const creationEndpointKey = session?.endpointDigest ?? session?.endpointKey ?? "unbound"; - if (this.#malformedTopicCreateEndpoints.get(sessionId) === creationEndpointKey) + const malformedCreateEndpoint = this.#malformedTopicCreateEndpoints.get(sessionId); + if (malformedCreateEndpoint === "unbound" || malformedCreateEndpoint === creationEndpointKey) throw new Error("createForumTopic: invalid message_thread_id"); const existing = this.topics.get(sessionId); - if (existing?.authorityState === "delete_pending" || existing?.bindingMalformed) return undefined; + if (existing && (existing.authorityState !== "active" || existing.bindingMalformed)) return undefined; if (existing) return existing.topicId; if ( session && @@ -7227,9 +7835,9 @@ export class TelegramNotificationDaemon { const creationLeaseEpoch = this.topics.authorityEpoch(sessionId); let acceptedTopicId: string | undefined; let acceptedTopicCompensated = false; - let acceptedTopicDeleteAttempted = false; + let acceptedTopicArchiveAttempted = false; let creationSuppressed = false; - /** User-created topicId adopted in this callback, if any (for sidecar reconciliation). */ + let creationRejected = false; let adoptedTopicId: number | undefined; const adoptionIntentCandidate = this.#adoptionIntents.bySession(sessionId); try { @@ -7279,57 +7887,52 @@ export class TelegramNotificationDaemon { }; const tid = response.result?.message_thread_id; if (typeof tid !== "number" || !Number.isSafeInteger(tid) || tid <= 0) { - if (response.ok === true) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey); + creationRejected = response.ok === false; + if (!creationRejected) this.#malformedTopicCreateEndpoints.set(sessionId, creationEndpointKey); throw new Error("createForumTopic: invalid message_thread_id"); } acceptedTopicId = String(tid); this.#malformedTopicCreateEndpoints.delete(sessionId); if (capturedCreationLease && !(await this.#awaitCreationLeaseAuthority(capturedCreationLease))) { - const fencedCreate = this.topics.fenceAcceptedCreateForLease( - sessionId, - acceptedTopicId, - creationLeaseEpoch, - this.opts.now, - name, - creationBinding, - ); - if (!fencedCreate) throw new Error("topic authority was revoked during creation"); - // Epoch held when the compensating delete is dispatched below; a - // concurrent re-fence must not be settled by this delete's result. - const fencedCreateEpoch = fencedCreate.authorityEpoch ?? 0; + if ( + !this.topics.fenceAcceptedCreateForLease( + sessionId, + acceptedTopicId, + creationLeaseEpoch, + this.installationHostId, + this.opts.now, + name, + creationBinding, + undefined, + String(this.opts.chatId), + ) + ) + throw new Error("topic authority was revoked during creation"); try { await this.persistTopics(); - } finally { - acceptedTopicDeleteAttempted = true; - const deletion = await this.botApi.call("deleteForumTopic", { - chat_id: this.opts.chatId, - message_thread_id: tid, - }); - // Remote compensation succeeded, but the transaction is not complete - // until the registry clear is durable. `acceptedTopicCompensated` is - // what tells outer recovery to stop supervising the fence, so it is - // set only after the phase-2 commit: a failed clear persist leaves - // the fence supervised instead of stranding a cleared memory state - // against a `delete_pending` disk state. - if (topicDeleteSettled(deletion)) { - const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCreateEpoch); - if (!settled) { - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); - } else - try { - await this.persistTopics(); - this.topics.commitSettledDelete(settled); - acceptedTopicCompensated = true; - } catch { - this.topics.rollbackSettledDelete(settled); - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); - } - } else { - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); + } catch (error) { + this.topics.scheduleArchiveRetry( + sessionId, + this.runtime.now(), + "archive fence persistence failed", + ); + try { + await this.#persistTopicsWithRetry(); + } catch { + await this.#superviseCompensationFence(sessionId); } + throw error; + } + if ( + this.#acceptedCreateArchiveFenceAllows( + sessionId, + acceptedTopicId, + creationLeaseEpoch, + creationBinding, + ) + ) { + acceptedTopicArchiveAttempted = true; + acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled"; } throw new Error("topic authority was revoked during creation"); } @@ -7342,9 +7945,8 @@ export class TelegramNotificationDaemon { session, adoptionIntentCandidate ? "user_created" : undefined, ); - // Adoption commit success: remove the durable sidecar. A cleanup failure - // cannot roll back the committed record; topic success is retained and the - // stale sidecar is left for startup reconciliation to remove safely. + // Adoption commit success removes only the sidecar. The committed + // user-created topic remains retained even when sidecar cleanup fails. if (adoptedTopicId !== undefined) { if (adoptionIntentCandidate) logger.info( @@ -7359,6 +7961,10 @@ export class TelegramNotificationDaemon { ); } } + // Publish the durable host lease before rechecking the captured socket + // lease. The recheck consults the registry once a record exists, so the + // newly-created record must already authorize this host. + if (!(await this.#renewTopicLease(sessionId))) return undefined; // getOrCreateTopic deduplicates callers, so an accepted create can be // observed by a successor after its initiating socket was revoked. Check // the immutable lease again before exposing that record to frame delivery. @@ -7380,78 +7986,100 @@ export class TelegramNotificationDaemon { sessionId, rec.topicId, creationLeaseEpoch, + this.installationHostId, this.opts.now, name, creationBinding, + undefined, + String(this.opts.chatId), ) - ) - await this.deleteTopic(sessionId, undefined, true); + ) { + try { + await this.persistTopics(); + } catch { + this.#superviseCompensationFence(sessionId); + return undefined; + } + if (this.#acceptedCreateArchiveFenceAllows(sessionId, rec.topicId, creationLeaseEpoch, creationBinding)) + await this.archiveTopic(sessionId, undefined, true); + } return undefined; } return rec.topicId; } catch (err) { - if (creationSuppressed || err instanceof ThreadedModeCapabilityRefusal) return undefined; - // Adoption commit failure: release the in-memory claim so a retry can - // re-claim, and retain the durable sidecar (never delete the user topic). - if (adoptedTopicId !== undefined) { - this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId); + if (adoptedTopicId !== undefined) this.#adoptionIntents.releaseClaim(adoptedTopicId, sessionId); + if (adoptionIntentCandidate) this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch); + if (creationSuppressed || creationRejected || err instanceof ThreadedModeCapabilityRefusal) { + if (this.topics.abandonCreateClaim(sessionId, creationLeaseEpoch)) await this.persistTopics(); + return undefined; + } + const revokedAcceptedRecord = acceptedTopicId ? this.topics.get(sessionId) : undefined; + if ( + revokedAcceptedRecord !== undefined && + revokedAcceptedRecord.topicId === acceptedTopicId && + revokedAcceptedRecord.authorityState === "archive_pending" && + !this.topics.archiveAuthorityAllows( + sessionId, + this.installationHostId, + this.opts.chatId, + this.runtime.now(), + ) + ) { + if ( + revokedAcceptedRecord.chatId === undefined && + revokedAcceptedRecord.endpointKey === undefined && + revokedAcceptedRecord.endpointDigest === undefined + ) + revokedAcceptedRecord.chatId = String(this.opts.chatId); + this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now()); + await this.persistTopics(); } if ( acceptedTopicId && !acceptedTopicCompensated && - !acceptedTopicDeleteAttempted && - this.topics.get(sessionId)?.authorityState !== "delete_pending" + !acceptedTopicArchiveAttempted && + this.topics.get(sessionId)?.authorityState !== "archive_pending" ) { // A failed initial commit must never make compensation conditional on // successfully publishing its fence. - const fencedCompensation = this.topics.fenceAcceptedCreateForLease( - sessionId, - acceptedTopicId, - creationLeaseEpoch, - this.opts.now, - name, - creationBinding, - ); - if (fencedCompensation) { - // Epoch held when the compensating delete is dispatched below; a - // concurrent re-fence must not be settled by this delete's result. - const fencedCompensationEpoch = fencedCompensation.authorityEpoch ?? 0; + if ( + this.topics.fenceAcceptedCreateForLease( + sessionId, + acceptedTopicId, + creationLeaseEpoch, + this.installationHostId, + this.opts.now, + name, + creationBinding, + undefined, + String(this.opts.chatId), + ) + ) { try { await this.#persistTopicsWithRetry(); } catch { - this.#superviseCompensationFence(sessionId); + await this.#superviseCompensationFence(sessionId); } - try { - const deletion = await this.botApi.call("deleteForumTopic", { - chat_id: this.opts.chatId, - message_thread_id: Number(acceptedTopicId), - }); - if (topicDeleteSettled(deletion)) { - const settled = this.topics.settleDelete(sessionId, acceptedTopicId, fencedCompensationEpoch); - if (!settled) { - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); - } else - try { - await this.persistTopics(); - this.topics.commitSettledDelete(settled); - } catch { - this.topics.rollbackSettledDelete(settled); - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); - } - } else { + if ( + this.#acceptedCreateArchiveFenceAllows( + sessionId, + acceptedTopicId, + creationLeaseEpoch, + creationBinding, + ) + ) { + try { + acceptedTopicArchiveAttempted = true; + acceptedTopicCompensated = (await this.archiveTopic(sessionId, undefined, true)) === "settled"; + } catch { this.#superviseCompensationFence(sessionId); await this.#persistTopicsWithRetry().catch(() => undefined); } - } catch { - this.#superviseCompensationFence(sessionId); - await this.#persistTopicsWithRetry().catch(() => undefined); } } } - if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicDeleteAttempted) { + if (acceptedTopicId && !acceptedTopicCompensated && acceptedTopicArchiveAttempted) { this.#superviseCompensationFence(sessionId); await this.#persistTopicsWithRetry().catch(() => undefined); } @@ -7480,24 +8108,75 @@ export class TelegramNotificationDaemon { if (!this.topicPastOrphanGrace(sessionId)) return; const currentOwner = this.logicalSessionOwners.get(sessionId); if (currentOwner && this.#leaseAllows(currentOwner, sessionId)) return; - await this.deleteTopic(sessionId); + await this.archiveTopic(sessionId); }); } /** Best-effort delete of a session topic once its local notification endpoint shuts down. */ - private async deleteTopic( + /** Join all close, compensation, orphan, and restart callers for one session. */ + private archiveTopic( sessionId: string, socketLease?: { session: SessionSocket; token: number; logicalSessionId: string }, - deleteFenceAlreadyPublished = false, + archiveFenceAlreadyPublished = false, ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> { - let record = deleteFenceAlreadyPublished ? this.topics.get(sessionId) : this.topics.beginDelete(sessionId); - // Authority epoch held for this delete. Captured before any dispatch so a - // concurrent scan/close re-fence of the same session cannot be settled by - // this delete's definite result. - const dispatchedAuthorityEpoch = this.topics.authorityEpoch(sessionId); + const active = this.archiveFlights.get(sessionId); + if (active) return active; + const flight = this.#archiveTopicOnce(sessionId, socketLease, archiveFenceAlreadyPublished); + this.archiveFlights.set(sessionId, flight); + void flight.then( + () => { + if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId); + }, + () => { + if (this.archiveFlights.get(sessionId) === flight) this.archiveFlights.delete(sessionId); + }, + ); + return flight; + } + + /** One durable archive fence and at most one remote close dispatch per flight. */ + async #archiveTopicOnce( + sessionId: string, + socketLease?: { session: SessionSocket; token: number; logicalSessionId: string }, + archiveFenceAlreadyPublished = false, + ): Promise<"pre_dispatch_cancelled" | "post_dispatch_pending" | "settled"> { + if (!(await this.pairedChatAllowsTopics())) return "pre_dispatch_cancelled"; + const existing = this.topics.get(sessionId); + // A completed archive retains an inactive record as historical evidence. + // Re-archiving it would duplicate the irreversible remote close request. + if (existing?.authorityState === "inactive") return "settled"; + if (existing?.topicOrigin === "user_created") { + if (existing.authorityState === "archive_pending") { + const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId); + const authorityEpoch = existing.authorityEpoch ?? Number.MAX_SAFE_INTEGER; + if (!this.topics.settleArchive(sessionId, existing.topicId, authorityEpoch)) return "post_dispatch_pending"; + try { + await this.persistTopics(); + } catch { + this.topics.restoreArchiveFence(archiveSnapshot); + await this.#persistTopicsWithRetry().catch(() => undefined); + return "post_dispatch_pending"; + } + } + return "settled"; + } + const archiveSnapshot = this.topics.captureArchiveAuthority(sessionId); + let record = archiveFenceAlreadyPublished + ? existing + : this.topics.beginArchive(sessionId, this.installationHostId, this.runtime.now()); if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled"; - await this.persistTopics(); + if ( + record && + !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now()) + ) + return "pre_dispatch_cancelled"; + if (!archiveFenceAlreadyPublished) await this.persistTopics(); if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled"; + if ( + record && + !this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now()) + ) + return "pre_dispatch_cancelled"; await this.#revokeAskAuthority(sessionId); this.deleteMessageRoutes(sessionId); this.#clearModelChoiceAliases(sessionId); @@ -7506,7 +8185,17 @@ export class TelegramNotificationDaemon { record = this.topics.get(sessionId); await this.persistTopics(); if (!record) return "settled"; + if ( + !this.topics.archiveAuthorityAllows( + sessionId, + this.installationHostId, + this.opts.chatId, + this.runtime.now(), + ) + ) + return "pre_dispatch_cancelled"; } + const dispatchedAuthorityEpoch = record.authorityEpoch ?? Number.MAX_SAFE_INTEGER; const removed = this.pool.removeWhere(item => item.sessionId === sessionId); for (const item of removed) { if (item.payload.selectedAck) @@ -7516,43 +8205,26 @@ export class TelegramNotificationDaemon { try { await this.flushPool(); if (socketLease && !this.#deleteLeaseAllows(socketLease)) return "pre_dispatch_cancelled"; - if (record.topicOrigin === "user_created") { - // Phase 1: drop the record but keep the topic id quarantined. Routes are - // only republished by `commitSettledDelete` once the clear is durable. - const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch); - if (!settled) { - await this.#persistTopicsWithRetry().catch(() => undefined); - return "post_dispatch_pending"; - } - for (const k of [...this.liveMessages.keys()]) - if (k.startsWith(`${sessionId}:`)) { - this.liveMessages.delete(k); - this.toolActivityOwners.delete(k); - } - this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => { - if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey); - }); - this.pendingThreadedFrames.delete(sessionId); - try { - await this.persistTopics(); - this.topics.commitSettledDelete(settled); - return "settled"; - } catch { - this.topics.rollbackSettledDelete(settled); - await this.#persistTopicsWithRetry().catch(() => undefined); - return "post_dispatch_pending"; - } - } - const res = (await this.botApi.call("deleteForumTopic", { + if ( + !this.topics.archiveAuthorityAllows( + sessionId, + this.installationHostId, + this.opts.chatId, + this.runtime.now(), + ) + ) + return "pre_dispatch_cancelled"; + const res = (await this.botApi.call("closeForumTopic", { chat_id: this.opts.chatId, message_thread_id: Number(record.topicId), })) as { ok?: boolean }; - if (!topicDeleteSettled(res)) return "post_dispatch_pending"; - const settled = this.topics.settleDelete(sessionId, record.topicId, dispatchedAuthorityEpoch); - if (!settled) { - await this.#persistTopicsWithRetry().catch(() => undefined); + if (!topicArchiveSettled(res)) { + this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive result was not definitive"); + await this.persistTopics(); return "post_dispatch_pending"; } + if (!this.topics.settleArchive(sessionId, record.topicId, dispatchedAuthorityEpoch)) + return "post_dispatch_pending"; for (const k of [...this.liveMessages.keys()]) if (k.startsWith(`${sessionId}:`)) { this.liveMessages.delete(k); @@ -7564,32 +8236,89 @@ export class TelegramNotificationDaemon { this.pendingThreadedFrames.delete(sessionId); try { await this.persistTopics(); - this.topics.commitSettledDelete(settled); return "settled"; } catch { - this.topics.rollbackSettledDelete(settled); + this.topics.restoreArchiveFence(archiveSnapshot); await this.#persistTopicsWithRetry().catch(() => undefined); return "post_dispatch_pending"; } } catch { - // Once Telegram dispatch starts, retain the persisted deletion fence: the + // Once Telegram dispatch starts, retain the persisted archive fence: the // remote result is ambiguous and may not restore stale routing authority. + this.topics.scheduleArchiveRetry(sessionId, this.runtime.now(), "archive transport failed"); + await this.persistTopics().catch(() => undefined); return "post_dispatch_pending"; } } /** Serialize a mutation and its durable snapshot so rollback precedes later writers. */ #persistTopicMutation(mutation: () => T, rollback: () => void): Promise { + if (this.validationMode()) return Promise.resolve().then(mutation); const pending = this.topicsPersistQueue.then(async () => { - const result = mutation(); + let result = mutation(); + const authority = this.opts.topicRegistryAuthority; + let sharedCommitted = false; + let latestWinner: TopicRegistryState | undefined; try { const snapshot = this.#topicStateForPersistence(); - const paths = daemonPaths(this.opts.settings.getAgentDir()); - await ensureDir(this.fsImpl, paths.dir); - await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot); + if (authority) { + for (let attempt = 0; attempt < 3; attempt++) { + const expectedGeneration = this.topics.registryVersion(); + const snapshot = this.#topicStateForPersistence(); + const nextGeneration = expectedGeneration + 1; + snapshot.registryGeneration = nextGeneration; + let accepted = false; + try { + accepted = await authority.compareAndSet(expectedGeneration, snapshot); + } catch { + throw new Error("shared topic authority unavailable"); + } + if (accepted) { + this.topics.markRegistryPublished(nextGeneration); + sharedCommitted = true; + break; + } + const winner = parseTopicRegistryState(await authority.read().catch(() => undefined)); + if (!winner) throw new Error("shared topic authority conflict"); + latestWinner = winner; + this.#replaceTopicAuthority(winner); + if (attempt === 2) throw new Error("shared topic authority conflict"); + result = mutation(); + } + } + if (!authority) { + const paths = daemonPaths(this.opts.settings.getAgentDir()); + await ensureDir(this.fsImpl, paths.dir); + const expectedDestination = await captureTopicRegistryDestination( + this.fsImpl, + path.join(paths.dir, "telegram-topics.json"), + process.platform, + ); + await writeTopicRegistryAtomic( + this.fsImpl, + path.join(paths.dir, "telegram-topics.json"), + snapshot, + process.platform, + exactReplacePath, + expectedDestination, + ); + } return result; } catch (error) { - rollback(); + if (sharedCommitted && authority) { + const committed = await authority.read().catch(() => undefined); + const state = parseTopicRegistryState(committed); + if (state) { + this.topics.replace(state); + this.closedEndpointKeys.clear(); + for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) + if (binding) this.closedEndpointKeys.set(sessionId, binding); + } + } else if (latestWinner) { + this.#replaceTopicAuthority(latestWinner); + } else { + rollback(); + } throw error; } }); @@ -7608,8 +8337,9 @@ export class TelegramNotificationDaemon { } } - #superviseCompensationFence(sessionId: string): void { - if (this.compensationFenceRetries.has(sessionId)) return; + async #superviseCompensationFence(sessionId: string): Promise { + const existing = this.compensationFenceRetries.get(sessionId); + if (existing) return await existing; const retry = this.effects.track( (async () => { for (;;) { @@ -7623,35 +8353,143 @@ export class TelegramNotificationDaemon { })(), ); this.compensationFenceRetries.set(sessionId, retry); - void retry.finally(() => this.compensationFenceRetries.delete(sessionId)); + try { + await retry; + } finally { + if (this.compensationFenceRetries.get(sessionId) === retry) this.compensationFenceRetries.delete(sessionId); + } } private persistTopics(): Promise { + if (this.validationMode()) return Promise.resolve(); const pending = this.topicsPersistQueue.then(async () => { // Resolve implicit snapshots inside the serialization queue. Callers that // mutate the registry before waiting cannot overwrite a newer authority // binding with an invocation-time snapshot. const snapshot = this.#topicStateForPersistence(); - const paths = daemonPaths(this.opts.settings.getAgentDir()); - await ensureDir(this.fsImpl, paths.dir); - await writeJsonAtomic(this.fsImpl, path.join(paths.dir, "telegram-topics.json"), snapshot); + const authority = this.opts.topicRegistryAuthority; + if (authority) { + const expectedGeneration = this.topics.registryVersion(); + const nextGeneration = expectedGeneration + 1; + snapshot.registryGeneration = nextGeneration; + let accepted = false; + try { + accepted = await authority.compareAndSet(expectedGeneration, snapshot); + } catch { + throw new Error("shared topic authority unavailable"); + } + if (!accepted) { + let winner: TopicRegistryState | undefined; + try { + winner = parseTopicRegistryState(await authority.read()); + } catch { + throw new Error("shared topic authority unavailable"); + } + if (!winner) throw new Error("shared topic authority conflict"); + this.#replaceTopicAuthority(winner); + throw new Error("shared topic authority conflict"); + } + this.topics.markRegistryPublished(nextGeneration); + } + if (!authority) { + const paths = daemonPaths(this.opts.settings.getAgentDir()); + await ensureDir(this.fsImpl, paths.dir); + const topicPath = path.join(paths.dir, "telegram-topics.json"); + const expectedDestination = await captureTopicRegistryDestination(this.fsImpl, topicPath, process.platform); + await writeTopicRegistryAtomic( + this.fsImpl, + topicPath, + snapshot, + process.platform, + exactReplacePath, + expectedDestination, + ); + } }); this.topicsPersistQueue = pending.catch(() => undefined); return pending; } + #acceptedCreateArchiveFenceAllows( + sessionId: string, + topicId: string, + creationLeaseEpoch: number, + binding: TopicEndpointBinding | undefined, + ): boolean { + const record = this.topics.get(sessionId); + const bindingMatches = binding + ? record?.chatId === binding.chatId && + record.endpointKey === binding.endpointKey && + record.endpointDigest === binding.endpointDigest && + record.endpointGeneration === binding.endpointGeneration + : record?.chatId === String(this.opts.chatId) && + record.endpointKey === undefined && + record.endpointDigest === undefined; + return ( + record?.topicId === topicId && + record.creationLeaseEpoch === creationLeaseEpoch && + bindingMatches && + this.topics.archiveAuthorityAllows(sessionId, this.installationHostId, this.opts.chatId, this.runtime.now()) + ); + } + + #replaceTopicAuthority(state: TopicRegistryState): void { + this.topics.replace(state); + this.closedEndpointKeys.clear(); + for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) + if (binding) this.closedEndpointKeys.set(sessionId, binding); + } + #topicStateForPersistence(): TopicRegistryState { - return { ...this.topics.serialize(), closedEndpoints: Object.fromEntries(this.closedEndpointKeys) }; + return { + ...this.topics.serialize(), + closedEndpoints: Object.fromEntries(this.closedEndpointKeys), + ...(this.opts.topicRegistryAuthority ? {} : { installationHostId: this.installationHostId }), + }; } async loadTopics(): Promise { + if (this.validationMode()) return; const paths = daemonPaths(this.opts.settings.getAgentDir()); - const raw = await readJson(this.fsImpl, path.join(paths.dir, "telegram-topics.json")); + const topicPath = path.join(paths.dir, "telegram-topics.json"); + let raw = await readJson(this.fsImpl, topicPath); + if (this.opts.topicRegistryAuthority) { + try { + raw = await this.opts.topicRegistryAuthority.read(); + } catch { + throw new Error("shared topic authority unavailable"); + } + if (!raw) throw new Error("shared topic authority unavailable"); + } + const legacySnapshot = + !!raw && typeof raw === "object" && !Array.isArray(raw) && !Object.hasOwn(raw as object, "version"); + if (legacySnapshot && raw !== undefined) { + const legacyDigest = crypto.createHash("sha256").update(JSON.stringify(raw)).digest("hex"); + const quarantinePath = path.join(paths.dir, `telegram-topics.legacy-quarantine.${legacyDigest}.json`); + if ((await readJson(this.fsImpl, quarantinePath)) === undefined) { + await ensureDir(this.fsImpl, paths.dir); + await writeTopicRegistryAtomic(this.fsImpl, quarantinePath, raw); + } + } + const state = parseTopicRegistryState(raw); // Restore the full serialized registry (topicId + identitySent + name) so a // fresh daemon after reload does not resend identity headers or lose renames. - if (raw && typeof raw === "object") { - this.topics.load(raw); - for (const [sessionId, binding] of Object.entries(raw.closedEndpoints ?? {})) { + if (state) { + const missingHostId = + !this.opts.topicRegistryAuthority && + (typeof state.installationHostId !== "string" || state.installationHostId.length === 0); + const missingSessionUuid = Object.values(state.topics).some( + record => !!record && typeof record === "object" && typeof record.sessionUuid !== "string", + ); + this.topics.load(state); + if (!this.opts.topicRegistryAuthority && typeof state.installationHostId === "string") + this.installationHostId = state.installationHostId; + let reconciledCreateClaim = false; + for (const claim of this.topics.pendingCreateClaims()) { + const topic = this.topics.get(claim.sessionId); + reconciledCreateClaim = this.topics.reconcileCreateClaim(claim.sessionId, topic) || reconciledCreateClaim; + } + for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) { if ( binding && typeof binding.chatId === "string" && @@ -7662,14 +8500,14 @@ export class TelegramNotificationDaemon { ) this.closedEndpointKeys.set(sessionId, binding); } + if (legacySnapshot || missingHostId || missingSessionUuid || reconciledCreateClaim) await this.persistTopics(); } } /** - * Rehydrate durable adoption intents after restart, then reconcile: a - * non-expired sidecar whose target session already has a committed topic - * record is a stale leftover (commit succeeded but sidecar cleanup failed) - * and is removed safely without touching the user topic. + * Rehydrate durable adoption intents after restart. A sidecar whose topic + * already committed is stale evidence and can be removed without touching + * the retained user-created topic. */ async loadAdoptionIntents(): Promise { await this.#adoptionIntents.rehydrate(); @@ -7692,7 +8530,6 @@ export class TelegramNotificationDaemon { } } - /** Periodic sweep of expired adoption-intent sidecars (files only; no Telegram API). */ private startAdoptionSweepTimer(): void { const setIntervalImpl = this.opts.setIntervalImpl ?? setInterval; this.#adoptionSweepTimer = setIntervalImpl(() => { @@ -7715,9 +8552,10 @@ export class TelegramNotificationDaemon { } } - /** Retry crash-interrupted topic deletes; only a definite Telegram result clears the durable fence. */ + /** Retry crash-interrupted topic archives only when the durable backoff permits it. */ private async reconcilePendingTopicDeletes(): Promise { - for (const sessionId of this.topics.deletePendingSessionIds()) await this.deleteTopic(sessionId); + for (const sessionId of this.topics.archivePendingSessionIds(this.runtime.now())) + await this.archiveTopic(sessionId); } /** Download one Telegram file with the Bot API's 20 MiB ceiling and one end-to-end deadline. */ @@ -8161,7 +8999,7 @@ export class TelegramNotificationDaemon { continue; } const topicId = topicLease?.topicId; - if (topicId && !(await this.pairedChatIsPrivate())) { + if (topicId && !(await this.pairedChatAllowsTopics())) { this.pool.settle(item.itemId!, "rejected"); this.failLegacyToolStart(toolActivity); continue; @@ -8324,7 +9162,7 @@ export class TelegramNotificationDaemon { ); // Index the sent rich message so an inbound reply to it can restore // the original markdown as context (Telegram does not echo it back). - if (richMessageId !== undefined) { + if (richMessageId !== undefined && !this.validationMode()) { await this.replyStore.record({ chatId: this.opts.chatId, messageId: richMessageId, @@ -8631,29 +9469,41 @@ export class TelegramNotificationDaemon { } /** - * Resolve (and cache definitive resolution of) whether the paired `chatId` is - * a private chat. Topic and flat delivery are only safe in a private DM; an - * indeterminate `getChat` result fails closed for this attempt and is retried - * later. + * Resolve and cache the outbound delivery boundary. The validation forum is + * explicit and topic-only; all inbound and flat paths remain private-only. */ + private validationMode(): boolean { + return this.opts.validationTestSupergroupChatId !== undefined; + } + private async resolvePairedChatPrivacy(): Promise { - if (this.pairedChatPrivate !== undefined) return this.pairedChatPrivate ? "private" : "non-private"; + if (this.pairedChatPrivacy !== undefined) return this.pairedChatPrivacy; + const chatId = this.opts.validationTestSupergroupChatId ?? this.opts.chatId; try { - const res = (await this.botApi.call("getChat", { chat_id: this.opts.chatId })) as { + const res = (await this.botApi.call("getChat", { chat_id: chatId })) as { ok?: unknown; - result?: { type?: unknown }; + result?: { id?: unknown; type?: unknown; is_forum?: unknown }; }; if (res === undefined) return "indeterminate"; if (res?.ok !== true) { logger.warn("notifications: getChat privacy check indeterminate (non-success response)"); return "indeterminate"; } - if (res.result?.type === "private") { - this.pairedChatPrivate = true; + if ( + this.validationMode() && + String(res.result?.id) === this.opts.validationTestSupergroupChatId && + res.result?.type === "supergroup" && + res.result.is_forum === true + ) { + this.pairedChatPrivacy = "validation-forum"; + return "validation-forum"; + } + if (!this.validationMode() && res.result?.type === "private") { + this.pairedChatPrivacy = "private"; return "private"; } if (res.result?.type === "group" || res.result?.type === "supergroup" || res.result?.type === "channel") { - this.pairedChatPrivate = false; + this.pairedChatPrivacy = "non-private"; return "non-private"; } logger.warn("notifications: getChat privacy check indeterminate (missing or invalid chat type)"); @@ -8664,7 +9514,12 @@ export class TelegramNotificationDaemon { } } - /** Keep existing outbound callers fail-closed for indeterminate privacy. */ + private async pairedChatAllowsTopics(): Promise { + const privacy = await this.resolvePairedChatPrivacy(); + return privacy === "private" || privacy === "validation-forum"; + } + + /** Keep all flat delivery and inbound control paths private-only. */ private async pairedChatIsPrivate(): Promise { return (await this.resolvePairedChatPrivacy()) === "private"; } @@ -8962,10 +9817,13 @@ export class TelegramNotificationDaemon { endpointAuthority.sessionId === session.sessionId && ownsLiveOpenEndpoint && this.topics.matchesEndpoint(session.sessionId, endpointBinding); + const inactiveSuccessor = + this.topics.get(session.sessionId)?.authorityState === "inactive" && + this.topics.get(session.sessionId)?.endpointDigest !== endpointBinding.endpointDigest; const canBootstrapTransport = - endpointAuthority.state === "none" && + (endpointAuthority.state === "none" || inactiveSuccessor) && ownsLiveOpenEndpoint && - !this.topics.get(session.sessionId) && + (!this.topics.get(session.sessionId) || inactiveSuccessor) && !this.preservedInitiatorTopics.has(session.sessionId); const replayCandidateSessionId = replayIdentitySessionId ?? (canResumeTransport || canBootstrapTransport ? session.sessionId : undefined); @@ -8977,6 +9835,7 @@ export class TelegramNotificationDaemon { true, replayIdentitySessionId ? undefined : canBootstrapTransport ? "bootstrap" : "resume", ); + if (this.sessions.get(session.sessionId) !== session) return; if (!recovered) { if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending") this.dropSession(session, "recovery_rejected"); @@ -9389,10 +10248,8 @@ export class TelegramNotificationDaemon { abandonStaleToolStart(); return; } - if ( - this.topics.get(logicalSessionId)?.authorityState === "delete_pending" || - this.topics.get(logicalSessionId)?.bindingMalformed - ) { + const topicRecord = this.topics.get(logicalSessionId); + if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) { this.failLegacyToolStart(toolActivity); return; } @@ -9405,10 +10262,8 @@ export class TelegramNotificationDaemon { (await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session)); const topicLease = await this.topicAuthorityLease(logicalSessionId); if (!topicId || !topicLease || topicLease.topicId !== topicId) { - if ( - this.topics.get(logicalSessionId)?.authorityState === "delete_pending" || - this.topics.get(logicalSessionId)?.bindingMalformed - ) { + const topicRecord = this.topics.get(logicalSessionId); + if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) { this.failLegacyToolStart(toolActivity); return; } @@ -9473,11 +10328,8 @@ export class TelegramNotificationDaemon { session.pending.set(msg.id, pendingAction); await this.reissuePendingAction(logicalSessionId, msg.id); } - if ( - this.topics.get(logicalSessionId)?.authorityState === "delete_pending" || - this.topics.get(logicalSessionId)?.bindingMalformed - ) - return; + const topicRecord = this.topics.get(logicalSessionId); + if (topicRecord && (topicRecord.authorityState !== "active" || topicRecord.bindingMalformed)) return; const topicId = await this.ensureTopic(logicalSessionId, this.topicNameFor(logicalSessionId, msg), session); const topicLease = topicId ? this.topicAuthorityLeaseFromRegistry(logicalSessionId) : undefined; if (topicId && (!topicLease || topicLease.topicId !== topicId)) return; @@ -10530,6 +11382,7 @@ export class TelegramNotificationDaemon { } private async processTelegramUpdate(update: unknown): Promise { + if (this.validationMode()) return "consumed"; const createdOutcome = await this.handleForumTopicCreatedUpdate(update); if (createdOutcome !== "not-topic") return createdOutcome; const topicOutcome = await this.handleForumTopicEdited(update); @@ -10544,11 +11397,7 @@ export class TelegramNotificationDaemon { } async handleTelegramUpdate(update: unknown): Promise { - // A user-created forum topic (`forum_topic_created`) is consumed first: it - // authenticates the chat/user, shows the folder-source picker, and emits the - // "already starting" dedup notice for a topic with a live adoption intent. - // This mirrors the poll-loop ordering in processTelegramUpdate so direct - // (test/orchestration) callers observe identical created-topic handling. + if (this.validationMode()) return; if ((await this.handleForumTopicCreatedUpdate(update)) !== "not-topic") return; if ((await this.handleForumTopicEdited(update)) !== "not-topic") return; // A raw path is accepted only after the explicit direct-entry choice. The exact @@ -10710,7 +11559,17 @@ export class TelegramNotificationDaemon { // update_id dedupe are all enforced by decideThreadedInbound. const raw = update as { callback_query?: unknown; - message?: { text?: unknown; reply_to_message?: { message_id?: unknown } }; + message?: { + text?: unknown; + reply_to_message?: { message_id?: unknown }; + photo?: unknown; + document?: unknown; + video?: unknown; + audio?: unknown; + chat?: { id?: unknown }; + message_thread_id?: unknown; + message_id?: unknown; + }; }; // A reply to a known ask message routes to that ask (below). Any OTHER // message in a topic (plain text, or a reply to a non-ask message) is a @@ -10720,6 +11579,23 @@ export class TelegramNotificationDaemon { typeof raw.message?.text === "string" ? parseBtwCommand(raw.message.text, this.botUsername) : undefined; const isAskReply = replyTo !== undefined && (this.messageRoutes.has(String(replyTo)) || this.messageRoutes.has(Number(replyTo))); + if ( + reservedBtw?.kind === "question" && + (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) && + typeof raw.message?.message_thread_id === "number" && + typeof raw.message.message_id === "number" && + String(raw.message.chat?.id) === this.opts.chatId && + (await this.pairedChatIsPrivate()) + ) { + if (!(await this.reserveSeenUpdateId((update as { update_id?: number }).update_id!))) return; + await this.#sendBtwMessage({ + threadId: String(raw.message.message_thread_id), + messageId: raw.message.message_id, + text: BTW_USAGE_TEXT, + isAuthoritative: () => true, + }); + return; + } const directControl = typeof raw.message?.text === "string" ? parseTelegramControlCommand(raw.message.text, this.botUsername) @@ -10753,11 +11629,17 @@ export class TelegramNotificationDaemon { return undefined; return transportOwner.logicalSessionId; } + if ( + reservedBtw?.kind === "question" && + (raw.message?.photo || raw.message?.document || raw.message?.video || raw.message?.audio) + ) + return topicSessionId; return [...this.sessions.values()].some( session => - session.sessionId === topicSessionId || - this.#logicalSessionId(session) === topicSessionId || - session.recoveryLease?.logicalSessionId === topicSessionId, + (session.sessionId === topicSessionId || + this.#logicalSessionId(session) === topicSessionId || + session.recoveryLease?.logicalSessionId === topicSessionId) && + session.ws.readyState !== WebSocket.CLOSED, ) ? undefined : topicSessionId; @@ -10770,6 +11652,7 @@ export class TelegramNotificationDaemon { const preliminaryControl = inbound.attachment ? { kind: "none" as const } : parseTelegramControlCommand(inbound.text, this.botUsername); + const reservedBtw = parseBtwCommand(inbound.text, this.botUsername); if (preliminaryControl.kind === "ignored") return; const session = this.logicalSessionOwners.get(inbound.sessionId) ?? @@ -10799,6 +11682,20 @@ export class TelegramNotificationDaemon { topicLeaseAllows(); const routeLeaseAllows = (): boolean => topicLeaseAllows() && (!session || (!!routeLease && this.#leaseTokenAllows(routeLease))); + if ( + /^\/btw(?:\s|$)/i.test(inbound.text) && + inbound.attachment && + (!session || session.ws.readyState !== WebSocket.OPEN || !routeLease) + ) { + if (!(await this.reserveSeenUpdateId(inbound.updateId))) return; + await this.#sendBtwMessage({ + threadId: inbound.threadId, + messageId: inbound.messageId, + text: BTW_USAGE_TEXT, + isAuthoritative: () => true, + }); + return; + } const reserveRouteUpdate = async (): Promise => { if (!(await this.reserveSeenUpdateId(inbound.updateId))) return false; if (routeLeaseAllows()) return true; @@ -10806,9 +11703,18 @@ export class TelegramNotificationDaemon { return false; }; - if (session && !routeLease) return; - if (preliminaryControl.kind === "invalid" && session?.ws.readyState !== WebSocket.OPEN) return; - if (preliminaryControl.kind === "command" && session?.ws.readyState !== WebSocket.OPEN) { + if (session && !routeLease && !(reservedBtw?.kind === "question" && inbound.attachment)) return; + if ( + preliminaryControl.kind === "invalid" && + session?.ws.readyState !== WebSocket.OPEN && + !(reservedBtw?.kind === "question" && inbound.attachment) + ) + return; + if ( + preliminaryControl.kind === "command" && + session?.ws.readyState !== WebSocket.OPEN && + !(reservedBtw?.kind === "question" && inbound.attachment) + ) { if (await reserveRouteUpdate()) { try { await this.botApi.call("sendMessage", { @@ -10825,18 +11731,22 @@ export class TelegramNotificationDaemon { } return; } - const reservedBtw = parseBtwCommand(inbound.text, this.botUsername); if (reservedBtw?.kind === "ignored") { await this.rememberSeenUpdateId(inbound.updateId); return; } if (reservedBtw?.kind === "question" && (!reservedBtw.question || inbound.attachment)) { - if (!(await reserveRouteUpdate())) return; + const reserved = + session?.ws.readyState === WebSocket.OPEN + ? await reserveRouteUpdate() + : await this.reserveSeenUpdateId(inbound.updateId); + if (!reserved) return; + if (session?.ws.readyState !== WebSocket.OPEN) await this.flushPool(); await this.#sendBtwMessage({ threadId: inbound.threadId, messageId: inbound.messageId, text: BTW_USAGE_TEXT, - isAuthoritative: routeLeaseAllows, + isAuthoritative: session?.ws.readyState === WebSocket.OPEN ? routeLeaseAllows : () => true, }); return; } @@ -11228,6 +12138,12 @@ export class TelegramNotificationDaemon { // Runtime callers can bypass TypeScript's option type. Without a valid bot // token, there is no authenticated daemon identity or lifecycle authority. if (!validBotToken(this.opts.botToken)) return; + if (this.validationMode()) { + if (!/^-100\d+$/.test(this.opts.validationTestSupergroupChatId!)) + throw new Error("validation forum destination must be a negative -100... chat ID"); + if ((await this.resolvePairedChatPrivacy()) !== "validation-forum") + throw new Error("validation forum destination must be the exact supergroup forum returned by getChat"); + } let ownershipProved = false; try { const renewed = await renewDaemonHeartbeat({ @@ -11247,20 +12163,22 @@ export class TelegramNotificationDaemon { if (!this.running) return; // Self-heal durable notification state before any scan/poll work so // `daemon reload` recovers dead roots + leak artifacts (#2956). - try { - await healTelegramDaemonNotificationState({ - settings: this.opts.settings, - fs: this.fsImpl, - now: this.opts.now, - }); - } catch (error) { - logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`); + if (!this.validationMode()) { + try { + await healTelegramDaemonNotificationState({ + settings: this.opts.settings, + fs: this.fsImpl, + now: this.opts.now, + }); + } catch (error) { + logger.warn(`notifications: startup self-heal failed: ${sanitizeDiagnostic(String(error))}`); + } } await this.loadAliases(); // Owner-only: start lifecycle control immediately after ownership proof, // before timers or pre-poll startup work can invalidate this run. // Best-effort; notification delivery remains available on failure. - await this.startLifecycleControl(); + if (!this.validationMode()) await this.startLifecycleControl(); // A stop may arrive while lifecycle startup awaits its control endpoint. // Do not re-enable runtime work after that stop; close the partial server. if (!this.running) return; @@ -11269,13 +12187,19 @@ export class TelegramNotificationDaemon { this.startFlushTimer(); this.startScanTimer(); this.startTypingTimer(); - await this.refreshBotIdentity(); - await this.registerBotCommands(); + if (!this.validationMode()) { + await this.refreshBotIdentity(); + await this.registerBotCommands(); + } await this.loadTopics(); - await this.loadAdoptionIntents(); - this.startAdoptionSweepTimer(); - await this.loadSeenUpdateIds(); - await this.replyStore.load(); + if (!this.validationMode()) { + await this.loadAdoptionIntents(); + this.startAdoptionSweepTimer(); + } + if (!this.validationMode()) { + await this.loadSeenUpdateIds(); + await this.replyStore.load(); + } await this.runScan(); let idleSince = this.runtime.now(); while (this.running) { @@ -11355,10 +12279,12 @@ export class TelegramNotificationDaemon { await this.#drainBtwTurns(); await this.toolTerminalizationChain; await this.cleanupAllAttachmentDirs(); - await this.persistAliases(); + if (!this.validationMode()) { + await this.persistAliases(); + await this.persistSeenUpdateIds(); + await this.opts.control?.clear?.(this.opts.ownerId); + } await this.persistTopics(); - await this.persistSeenUpdateIds(); - await this.opts.control?.clear?.(this.opts.ownerId); persisted = true; }); const deadline = Promise.withResolvers(); diff --git a/packages/coding-agent/src/sdk/bus/topic-registry.ts b/packages/coding-agent/src/sdk/bus/topic-registry.ts index 427f29319a..a858203897 100644 --- a/packages/coding-agent/src/sdk/bus/topic-registry.ts +++ b/packages/coding-agent/src/sdk/bus/topic-registry.ts @@ -1,12 +1,12 @@ +import { randomUUID } from "node:crypto"; /** * Per-session forum-topic registry for the threaded session surface. * - * Each GJC session owns one active Telegram forum topic in the paired private - * DM. The topic is created via `createForumTopic`, reused while the session - * remains active, and removed from the registry when the daemon deletes it on - * shutdown. The registry also tracks whether the one-time identity header has - * already been pinned, so it is sent exactly once per active topic, even across - * reconnects. + * Each GJC session owns one active Telegram forum topic. Remote archive closes + * daemon-created topics without deleting their durable records; rotated + * successors move inactive records into retained history before creating a new + * active authority. The registry also tracks whether the one-time identity + * header has already been pinned. * * State is a plain serialisable map persisted beside the daemon state files; * topic creation is injected so this module is pure and unit-testable without a @@ -14,9 +14,24 @@ */ /** Persisted record for one session's topic. */ +export type TopicLifecycleState = + | "active" + | "disconnect_grace" + | "archive_pending" + | "archive_exhausted" + | "inactive" + | "legacy_quarantined" + /** Read-only input compatibility; normalized to archive_pending during load. */ + | "delete_pending"; + +/** Persisted record for one immutable session identity's topic. */ export interface TopicRecord { /** Telegram forum topic id (message_thread_id). */ topicId: string; + /** Whether Telegram created this topic for the daemon or it was explicitly adopted from a user. */ + topicOrigin: "daemon_created" | "user_created"; + /** Immutable UUID record identity; never derive authority from a title or PID. */ + sessionUuid?: string; /** Whether the one-time identity header has been sent/pinned. */ identitySent: boolean; /** Creation timestamp (ms epoch). */ @@ -41,10 +56,8 @@ export interface TopicRecord { authorityEpoch?: number; /** Immutable authority epoch held when this remote topic create began. */ creationLeaseEpoch?: number; - /** An uncertain delete fences future creation and inbound routing. */ - authorityState?: "active" | "delete_pending"; - /** Provenance for topics created by the user and adopted by GJC. Missing means daemon-created. */ - topicOrigin?: "user_created"; + /** Durable non-destructive lifecycle state. */ + authorityState?: TopicLifecycleState; /** Telegram chat and endpoint authority last proven to use this topic. */ chatId?: string; /** Canonical endpoint tuple (URL + token) that currently holds the lease. */ @@ -55,18 +68,70 @@ export interface TopicRecord { endpointGeneration?: number; /** Monotonic authenticated endpoint handoffs; legacy bindings begin at zero. */ endpointIncarnation?: number; + /** Shared-authority lease owner (installation UUID), heartbeat, and expiry. */ + leaseOwner?: string; + leaseHeartbeatAt?: number; + leaseExpiresAt?: number; + /** Durable archive initiator; a live foreign owner cannot be displaced. */ + archiveHostId?: string; + /** Authority epoch captured by the archive initiator when it published the fence. */ + archiveLeaseEpoch?: number; + disconnectGraceExpiresAt?: number; /** True when persisted binding fields were present but malformed; recovery must fail closed. */ bindingMalformed?: true; } +/** Durable claim published before invoking createForumTopic. */ +export interface TopicCreateClaim { + sessionId: string; + hostId?: string; + leaseOwner?: string; + authorityEpoch: number; + createdAt: number; + binding?: TopicEndpointBinding; +} + +export interface ArchiveJob { + sessionId: string; + topicId: string; + /** Number of archive calls that have returned an ambiguous outcome. */ + attempt: number; + /** First ambiguous result; bounds retry lifetime. */ + firstAttemptAt?: number; + /** Compatibility read for pre-journal snapshots; normalized to `attempt`. */ + retryCount?: number; + backoffMs: number; + nextAttemptAt: number; + safeDiagnostic?: string; +} /** Serialisable shape persisted to disk. */ export interface TopicRegistryState { + /** Writer format. Missing is the quarantined legacy format; future versions fail closed. */ + version?: 2; + /** Monotonically increasing snapshot generation used by shared CAS stores. */ + registryGeneration?: number; /** sessionId -> record. */ topics: Record; - /** Durable deletion epochs retained after a definite delete. */ + /** Durable lifecycle epochs retained after an archive starts. */ fences?: Record; /** Closed transport endpoint leases; unchanged endpoint discovery remains fenced across restart. */ closedEndpoints?: Record; + /** Persistent host identity used to distinguish concurrent installations. */ + installationHostId?: string; + /** Bounded durable archive work; no topic record is physically deleted. */ + archiveJobs?: Record; + /** Durable create claims. A claim fences concurrent creators before remote I/O. */ + createClaims?: Record; + /** Retained inactive predecessors keyed by logical session id. */ + retiredTopics?: Record; +} +/** + * Shared registry authority. Filesystem atomic rename is sufficient for a single + * installation, but it cannot serialize two hosts sharing a state volume. + */ +export interface TopicRegistryCasAuthority { + read(): Promise; + compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise; } /** Authenticated runtime binding for a durable topic lease. */ @@ -84,7 +149,7 @@ export type TopicEndpointAuthority = | { state: "ambiguous" }; /** Conditional rollback token for a delete fence publication. */ -export interface TopicDeleteAuthoritySnapshot { +export interface TopicArchiveAuthoritySnapshot { sessionId: string; topicId?: string; authorityEpoch?: number; @@ -94,18 +159,6 @@ export interface TopicDeleteAuthoritySnapshot { record?: TopicRecord; } -/** - * Proof that a definite delete was settled in memory and still owes its durable - * commit. Handed back only by a settlement that actually happened, so a refused - * settlement structurally cannot be followed by a rollback. - */ -export interface TopicSettledDelete { - sessionId: string; - topicId: string; - /** Authority epoch proven current when the record was removed. */ - settledEpoch: number; -} - function isValidBindingString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } @@ -147,19 +200,179 @@ function isValidTopicId(value: unknown): value is string { typeof value === "string" && /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0 ); } - -/** - * Monotonic epoch successor that saturates instead of leaving the safe-integer - * range. Past `Number.MAX_SAFE_INTEGER` two distinct generations collapse onto - * the same IEEE-754 double, which would let a stale settlement clear a newer - * fence; settlement refuses the saturated value instead of overflowing past it. - */ function nextAuthorityEpoch(current: number): number { return current >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : current + 1; } export function emptyTopicRegistryState(): TopicRegistryState { - return { topics: {} }; + return { version: 2, topics: {} }; +} +/** + * Reject snapshots written by a newer daemon. Missing versions are preserved as + * evidence but quarantined: legacy records must never route or mutate remotely. + */ +export function parseTopicRegistryState(value: unknown): TopicRegistryState | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const state = value as TopicRegistryState; + if (state.version !== undefined && state.version !== 2) throw new Error("unsupported future Telegram topic state"); + if (!state.topics || typeof state.topics !== "object" || Array.isArray(state.topics)) return undefined; + if (state.version === undefined) { + if (state.registryGeneration !== undefined) throw new Error("malformed Telegram topic state"); + return parseTopicRegistryState({ + ...state, + version: 2, + registryGeneration: 0, + topics: Object.fromEntries( + Object.entries(state.topics).map(([sessionId, record]) => [ + sessionId, + record && typeof record === "object" + ? { + ...record, + topicOrigin: (record as TopicRecord).topicOrigin ?? "daemon_created", + authorityState: "legacy_quarantined", + } + : record, + ]), + ), + }); + } + + const isObject = (candidate: unknown): candidate is Record => + !!candidate && typeof candidate === "object" && !Array.isArray(candidate); + const validTimestamp = (candidate: unknown): candidate is number => + typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0; + const validEpoch = (candidate: unknown): candidate is number => + typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0; + const validOptionalString = (candidate: unknown): boolean => + candidate === undefined || isValidBindingString(candidate); + const validBinding = (candidate: unknown): candidate is TopicEndpointBinding => + isObject(candidate) && + isValidBindingString(candidate.chatId) && + isValidBindingString(candidate.endpointKey) && + isValidBindingString(candidate.endpointDigest) && + (candidate.endpointGeneration === undefined || isValidBindingGeneration(candidate.endpointGeneration)); + const malformed = (): never => { + throw new Error("malformed Telegram topic state"); + }; + + if ( + (state.registryGeneration !== undefined && !validEpoch(state.registryGeneration)) || + !isObject(state.topics) || + [state.fences, state.closedEndpoints, state.archiveJobs, state.createClaims, state.retiredTopics].some( + nested => nested !== undefined && !isObject(nested), + ) + ) + malformed(); + for (const [sessionId, raw] of Object.entries(state.topics)) { + if (!isValidBindingString(sessionId) || !isObject(raw) || !isValidTopicId(raw.topicId)) malformed(); + if (typeof raw.identitySent !== "boolean" || !validTimestamp(raw.createdAt)) malformed(); + if ( + !validOptionalString(raw.sessionUuid) || + (raw.orphanedAt !== undefined && !validTimestamp(raw.orphanedAt)) || + (raw.name !== undefined && typeof raw.name !== "string") || + (raw.topicOrigin !== "daemon_created" && raw.topicOrigin !== "user_created") || + (raw.nameOwner !== undefined && raw.nameOwner !== "user") || + (raw.nameReconcilePending !== undefined && typeof raw.nameReconcilePending !== "boolean") || + (raw.userNameUpdateId !== undefined && !validEpoch(raw.userNameUpdateId)) || + !validOptionalString(raw.identityKey) || + (raw.replayGeneration !== undefined && + (!Number.isSafeInteger(raw.replayGeneration) || raw.replayGeneration < 1)) || + (raw.replaySeq !== undefined && !validEpoch(raw.replaySeq)) || + (raw.authorityEpoch !== undefined && !validEpoch(raw.authorityEpoch)) || + (raw.creationLeaseEpoch !== undefined && !validEpoch(raw.creationLeaseEpoch)) || + (raw.authorityState !== undefined && + ![ + "active", + "disconnect_grace", + "archive_pending", + "archive_exhausted", + "inactive", + "legacy_quarantined", + "delete_pending", + ].includes(raw.authorityState as string)) || + !validOptionalString(raw.leaseOwner) || + (raw.leaseHeartbeatAt !== undefined && !validTimestamp(raw.leaseHeartbeatAt)) || + (raw.leaseExpiresAt !== undefined && !validTimestamp(raw.leaseExpiresAt)) || + !validOptionalString(raw.archiveHostId) || + (raw.archiveLeaseEpoch !== undefined && !validEpoch(raw.archiveLeaseEpoch)) || + (raw.disconnectGraceExpiresAt !== undefined && !validTimestamp(raw.disconnectGraceExpiresAt)) || + (raw.bindingMalformed !== undefined && raw.bindingMalformed !== true) + ) + malformed(); + const leaseFieldCount = [raw.leaseOwner, raw.leaseHeartbeatAt, raw.leaseExpiresAt].filter( + value => value !== undefined, + ).length; + if (leaseFieldCount !== 0 && leaseFieldCount !== 3) malformed(); + if ( + raw.authorityState === "disconnect_grace" + ? raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined + : raw.disconnectGraceExpiresAt !== undefined + ) + malformed(); + const hasBinding = hasAnyBinding(raw); + const hasArchiveOnlyChatIdentity = + (raw.authorityState === "archive_pending" || + raw.authorityState === "archive_exhausted" || + raw.authorityState === "inactive") && + isValidBindingString(raw.chatId) && + raw.endpointKey === undefined && + raw.endpointDigest === undefined && + raw.endpointGeneration === undefined && + raw.endpointIncarnation === undefined; + if ( + hasBinding && + !hasArchiveOnlyChatIdentity && + (!isValidBindingString(raw.chatId) || + !isValidBindingString(raw.endpointKey) || + !isValidBindingString(raw.endpointDigest) || + (raw.endpointGeneration !== undefined && !isValidBindingGeneration(raw.endpointGeneration)) || + (raw.endpointIncarnation !== undefined && !isValidBindingGeneration(raw.endpointIncarnation))) + ) + malformed(); + } + for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) { + if (!isValidBindingString(sessionId) || !Array.isArray(records)) malformed(); + for (const [index, record] of records.entries()) { + parseTopicRegistryState({ + version: 2, + registryGeneration: 0, + topics: { [`${sessionId}:retired:${index}`]: record }, + }); + } + } + for (const [sessionId, epoch] of Object.entries(state.fences ?? {})) + if (!isValidBindingString(sessionId) || !validEpoch(epoch)) malformed(); + for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) { + if ( + !isValidBindingString(sessionId) || + !isObject(claim) || + claim.sessionId !== sessionId || + !validEpoch(claim.authorityEpoch) || + !validTimestamp(claim.createdAt) || + !validOptionalString(claim.hostId) || + !validOptionalString(claim.leaseOwner) || + (claim.binding !== undefined && !validBinding(claim.binding)) + ) + malformed(); + } + for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) { + if ( + !isValidBindingString(sessionId) || + !isObject(job) || + job.sessionId !== sessionId || + !isValidTopicId(job.topicId) || + !validEpoch(job.attempt) || + !validEpoch(job.backoffMs) || + !validTimestamp(job.nextAttemptAt) || + (job.firstAttemptAt !== undefined && !validTimestamp(job.firstAttemptAt)) || + (job.retryCount !== undefined && !validEpoch(job.retryCount)) || + (job.safeDiagnostic !== undefined && typeof job.safeDiagnostic !== "string") + ) + malformed(); + } + for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {})) + if (!isValidBindingString(sessionId) || !validBinding(binding)) malformed(); + return state; } /** @@ -183,8 +396,14 @@ export class TopicRegistry { private readonly creatingBindings = new Map(); /** Monotonic authority epochs, including deletion fences for absent records. */ private readonly epochs = new Map(); - /** Phase-1 settlements awaiting their durable clear; their topic ids stay quarantined. */ - readonly #settling = new Map(); + /** Archive work is retained and retryable; records are never physically removed. */ + private readonly archiveJobs = new Map(); + /** Durable pre-create claims; remote creation is forbidden until published. */ + private readonly createClaims = new Map(); + /** Inactive predecessor evidence retained across successor generations. */ + private readonly retiredTopics = new Map(); + /** Generation of the last loaded/published snapshot. */ + private registryGeneration = 0; constructor(state: TopicRegistryState = emptyTopicRegistryState()) { this.topics = new Map(); @@ -197,15 +416,76 @@ export class TopicRegistry { this.byTopic.clear(); this.#ambiguousTopicIds.clear(); this.epochs.clear(); - this.#settling.clear(); + this.archiveJobs.clear(); + this.createClaims.clear(); + this.retiredTopics.clear(); this.load(state); } /** Merge serialized state and normalize authority fields from older releases. */ load(state: TopicRegistryState): void { + for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) + if (Array.isArray(records)) + this.retiredTopics.set( + sessionId, + records.map(record => ({ ...record })), + ); + for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) { + const attempt = + job && Number.isSafeInteger(job.attempt) && job.attempt >= 0 + ? job.attempt + : job && + typeof job.retryCount === "number" && + Number.isSafeInteger(job.retryCount) && + job.retryCount >= 0 + ? job.retryCount + : undefined; + if ( + job && + job.sessionId === sessionId && + isValidTopicId(job.topicId) && + attempt !== undefined && + Number.isFinite(job.nextAttemptAt) + ) + this.archiveJobs.set(sessionId, { + sessionId, + topicId: job.topicId, + attempt: Math.min(8, attempt), + firstAttemptAt: + typeof job.firstAttemptAt === "number" && Number.isFinite(job.firstAttemptAt) + ? job.firstAttemptAt + : job.nextAttemptAt, + backoffMs: + Number.isSafeInteger(job.backoffMs) && job.backoffMs >= 0 + ? Math.min(60_000, job.backoffMs) + : Math.min(60_000, 250 * 2 ** Math.min(8, attempt)), + nextAttemptAt: job.nextAttemptAt, + ...(typeof job.safeDiagnostic === "string" ? { safeDiagnostic: job.safeDiagnostic.slice(0, 256) } : {}), + }); + } + if (Number.isSafeInteger(state.registryGeneration) && (state.registryGeneration ?? -1) >= 0) + this.registryGeneration = Math.max(this.registryGeneration, state.registryGeneration!); for (const [sessionId, epoch] of Object.entries(state.fences ?? {})) { if (Number.isSafeInteger(epoch) && epoch >= 0) this.epochs.set(sessionId, epoch); } + for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) { + if ( + claim && + claim.sessionId === sessionId && + Number.isSafeInteger(claim.authorityEpoch) && + claim.authorityEpoch >= 0 && + Number.isFinite(claim.createdAt) && + (claim.binding === undefined || hasValidBinding(claim.binding)) + ) + this.createClaims.set(sessionId, { + sessionId, + authorityEpoch: claim.authorityEpoch, + createdAt: claim.createdAt, + ...(isValidBindingString(claim.hostId) ? { hostId: claim.hostId } : {}), + ...(isValidBindingString(claim.leaseOwner) ? { leaseOwner: claim.leaseOwner } : {}), + ...(claim.binding ? { binding: claim.binding } : {}), + }); + } for (const [sessionId, raw] of Object.entries(state.topics ?? {})) { if (!raw || !isValidTopicId(raw.topicId)) continue; @@ -217,6 +497,7 @@ export class TopicRegistry { (typeof raw.userNameUpdateId === "number" && Number.isSafeInteger(raw.userNameUpdateId) && raw.userNameUpdateId >= 0)); + const legacyDeletePending = (raw as { authorityState?: unknown }).authorityState === "delete_pending"; const hasValidReplayCursor = typeof raw.replayGeneration === "number" && Number.isSafeInteger(raw.replayGeneration) && @@ -239,6 +520,9 @@ export class TopicRegistry { const fenceSupersedesRecord = fenceEpoch > rawAuthorityEpoch; const record: TopicRecord = { topicId: raw.topicId, + topicOrigin: raw.topicOrigin === "user_created" ? "user_created" : "daemon_created", + sessionUuid: + typeof raw.sessionUuid === "string" && raw.sessionUuid.length > 0 ? raw.sessionUuid : randomUUID(), identitySent: raw.identitySent === true, createdAt: typeof raw.createdAt === "number" ? raw.createdAt : 0, ...(typeof raw.name === "string" ? { name: raw.name } : {}), @@ -258,10 +542,26 @@ export class TopicRegistry { : {}), ...(hasValidReplayCursor ? { replayGeneration: raw.replayGeneration, replaySeq: raw.replaySeq } : {}), authorityEpoch: Math.max(rawAuthorityEpoch, fenceEpoch), - ...(raw.authorityState === "delete_pending" || fenceSupersedesRecord - ? { authorityState: "delete_pending" as const } - : {}), - ...(raw.topicOrigin === "user_created" ? { topicOrigin: "user_created" as const } : {}), + ...(raw.authorityState === "disconnect_grace" || + raw.authorityState === "archive_pending" || + raw.authorityState === "archive_exhausted" || + raw.authorityState === "inactive" || + raw.authorityState === "legacy_quarantined" || + legacyDeletePending || + fenceSupersedesRecord + ? { + authorityState: + raw.authorityState === "inactive" + ? ("inactive" as const) + : raw.authorityState === "legacy_quarantined" + ? ("legacy_quarantined" as const) + : raw.authorityState === "archive_exhausted" + ? ("archive_exhausted" as const) + : raw.authorityState === "disconnect_grace" && !fenceSupersedesRecord + ? ("disconnect_grace" as const) + : ("archive_pending" as const), + } + : { authorityState: "active" as const }), ...(isValidBindingString(raw.chatId) ? { chatId: raw.chatId } : {}), ...(isValidBindingString(raw.endpointKey) ? { endpointKey: raw.endpointKey } : {}), ...(isValidBindingString(raw.endpointDigest) ? { endpointDigest: raw.endpointDigest } : {}), @@ -269,6 +569,18 @@ export class TopicRegistry { ...(isValidBindingGeneration(raw.endpointIncarnation) ? { endpointIncarnation: raw.endpointIncarnation } : {}), + ...(isValidBindingString(raw.leaseOwner) ? { leaseOwner: raw.leaseOwner } : {}), + ...(typeof raw.leaseHeartbeatAt === "number" && Number.isFinite(raw.leaseHeartbeatAt) + ? { leaseHeartbeatAt: raw.leaseHeartbeatAt } + : {}), + ...(typeof raw.leaseExpiresAt === "number" && Number.isFinite(raw.leaseExpiresAt) + ? { leaseExpiresAt: raw.leaseExpiresAt } + : {}), + ...(isValidBindingString(raw.archiveHostId) ? { archiveHostId: raw.archiveHostId } : {}), + ...(isValidBindingGeneration(raw.archiveLeaseEpoch) ? { archiveLeaseEpoch: raw.archiveLeaseEpoch } : {}), + ...(typeof raw.disconnectGraceExpiresAt === "number" && Number.isFinite(raw.disconnectGraceExpiresAt) + ? { disconnectGraceExpiresAt: raw.disconnectGraceExpiresAt } + : {}), ...(bindingMalformed ? { bindingMalformed: true as const } : {}), }; this.epochs.set(sessionId, Math.max(fenceEpoch, record.authorityEpoch ?? 0)); @@ -284,14 +596,10 @@ export class TopicRegistry { private rebuildInboundRoutes(): void { this.byTopic.clear(); this.#ambiguousTopicIds.clear(); - // A settled-but-not-yet-durable clear keeps its topic id quarantined: the - // clear is not authoritative until persisted, so no colliding survivor may - // become routable and no settled id may become adoptable during that write. - for (const settling of this.#settling.values()) this.#ambiguousTopicIds.add(settling.topicId); const activeByTopic = new Map(); for (const [sessionId, record] of this.topics) { - if (record.authorityState === "delete_pending" || record.bindingMalformed) { + if (record.authorityState !== "active" || record.bindingMalformed) { this.#ambiguousTopicIds.add(record.topicId); continue; } @@ -307,13 +615,6 @@ export class TopicRegistry { } } - /** Advance and publish the session authority epoch, saturating at the safe-integer bound. */ - #advanceAuthorityEpoch(sessionId: string, base: number): number { - const epoch = nextAuthorityEpoch(base); - this.epochs.set(sessionId, epoch); - return epoch; - } - /** Resolve the owning session for a topic id (for fail-closed inbound routing). */ sessionForTopic(topicId: string): string | undefined { return this.byTopic.get(topicId); @@ -324,17 +625,50 @@ export class TopicRegistry { return [...this.topics.keys()]; } - /** Persisted remote deletes that must be reconciled before recovery can proceed. */ - deletePendingSessionIds(): string[] { - return [...this.topics].flatMap(([sessionId, record]) => - record.authorityState === "delete_pending" ? [sessionId] : [], - ); - } - /** The existing topic record for a session, if any. */ get(sessionId: string): TopicRecord | undefined { return this.topics.get(sessionId); } + /** Durable claims restored from disk require explicit authoritative reconciliation. */ + pendingCreateClaims(): TopicCreateClaim[] { + return [...this.createClaims.values()].map(claim => ({ + ...claim, + ...(claim.binding ? { binding: { ...claim.binding } } : {}), + })); + } + + /** + * Resolve a durable pre-create claim only with authoritative topic evidence. + * An absent or malformed topic deliberately leaves the claim in place, fencing + * subsequent creators after a crash. + */ + reconcileCreateClaim(sessionId: string, topic?: TopicRecord): boolean { + const claim = this.createClaims.get(sessionId); + if (!claim || !topic || !isValidTopicId(topic.topicId) || topic.authorityState !== "active") return false; + if (topic.authorityEpoch !== claim.authorityEpoch) return false; + if ( + claim.binding && + (!hasCompleteBinding(topic) || + !hasValidBinding(claim.binding) || + topic.chatId !== claim.binding.chatId || + topic.endpointKey !== claim.binding.endpointKey || + topic.endpointDigest !== claim.binding.endpointDigest || + topic.endpointGeneration !== claim.binding.endpointGeneration) + ) + return false; + this.topics.set(sessionId, { ...topic }); + this.createClaims.delete(sessionId); + this.rebuildInboundRoutes(); + return true; + } + /** Clear a claim only when the local create attempt proved no remote topic was accepted. */ + abandonCreateClaim(sessionId: string, authorityEpoch: number): boolean { + const claim = this.createClaims.get(sessionId); + if (!claim || claim.authorityEpoch !== authorityEpoch) return false; + this.createClaims.delete(sessionId); + this.creatingBindings.delete(sessionId); + return true; + } /** Current immutable authority epoch for a creation lease. */ authorityEpoch(sessionId: string): number { @@ -344,7 +678,7 @@ export class TopicRegistry { /** Whether this session has an active, unambiguous topic authority. */ isActiveUnambiguous(sessionId: string): boolean { const record = this.topics.get(sessionId); - return record?.authorityState !== "delete_pending" && this.byTopic.get(record?.topicId ?? "") === sessionId; + return record?.authorityState === "active" && this.byTopic.get(record?.topicId ?? "") === sessionId; } /** * Pure read-only availability check for user-topic adoption. Rejects invalid @@ -381,8 +715,13 @@ export class TopicRegistry { const creating = [...this.creatingBindings].filter( ([sessionId, claim]) => canClaim(claim) && !excludesClaimant(sessionId), ); - if (committed.length === 0 && staged.length === 0 && creating.length === 0) return { state: "none" }; - if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0) return { state: "ambiguous" }; + const durableClaims = [...this.createClaims].filter( + ([sessionId, claim]) => claim.binding !== undefined && canClaim(claim.binding) && !excludesClaimant(sessionId), + ); + if (committed.length === 0 && staged.length === 0 && creating.length === 0 && durableClaims.length === 0) + return { state: "none" }; + if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0 || durableClaims.length !== 0) + return { state: "ambiguous" }; const [sessionId, record] = committed[0]!; return record.chatId === binding.chatId && record.endpointKey === binding.endpointKey && @@ -411,6 +750,30 @@ export class TopicRegistry { ); } + /** + * Retire a remotely settled inactive topic only when an authenticated + * successor proves a different endpoint authority. The inactive predecessor + * is serialized into retained history before the active slot is released. + */ + retireInactiveEndpointForSuccessor(sessionId: string, binding: TopicEndpointBinding): boolean { + const record = this.topics.get(sessionId); + if ( + record?.authorityState !== "inactive" || + record.bindingMalformed || + !hasCompleteBinding(record) || + !hasValidBinding(binding) || + (record.endpointKey === binding.endpointKey && record.endpointDigest === binding.endpointDigest) + ) + return false; + const history = this.retiredTopics.get(sessionId) ?? []; + history.push({ ...record }); + this.retiredTopics.set(sessionId, history); + this.topics.delete(sessionId); + this.byTopic.delete(record.topicId); + this.archiveJobs.delete(sessionId); + this.createClaims.delete(sessionId); + return true; + } /** * Rebind an existing topic to an authenticated successor endpoint. The exact * logical session id is proved by replay before this method is called. A @@ -426,11 +789,10 @@ export class TopicRegistry { ): "bound" | "unchanged" | "rejected" { const record = this.topics.get(sessionId); if ( - !record || - record.authorityState === "delete_pending" || + (record?.authorityState !== "active" && record?.authorityState !== "disconnect_grace") || record.bindingMalformed || !hasValidBinding(binding) || - !this.isActiveUnambiguous(sessionId) + (record.authorityState === "active" && !this.isActiveUnambiguous(sessionId)) ) return "rejected"; if (hasAnyBinding(record) && !hasCompleteBinding(record)) return "rejected"; @@ -460,12 +822,18 @@ export class TopicRegistry { record.endpointKey !== binding.endpointKey || record.endpointDigest !== binding.endpointDigest || record.endpointGeneration !== binding.endpointGeneration; - if (!changed) return "unchanged"; + if (!changed && record.authorityState === "active") return "unchanged"; record.chatId = binding.chatId; record.endpointKey = binding.endpointKey; record.endpointDigest = binding.endpointDigest; record.endpointGeneration = binding.endpointGeneration; if (!sameEndpoint) record.endpointIncarnation = (record.endpointIncarnation ?? 0) + 1; + if (record.authorityState === "disconnect_grace") { + record.authorityState = "active"; + delete record.orphanedAt; + delete record.disconnectGraceExpiresAt; + this.rebuildInboundRoutes(); + } return "bound"; } @@ -512,29 +880,44 @@ export class TopicRegistry { topicOrigin?: TopicRecord["topicOrigin"], ): Promise { const existing = this.topics.get(sessionId); - if (existing?.authorityState === "delete_pending") throw new Error("topic authority is deletion-fenced"); + if (existing && existing.authorityState !== "active") throw new Error("topic authority is archive-fenced"); if (existing?.bindingMalformed) throw new Error("topic authority binding is quarantined"); if (existing) return existing; const pending = this.inflight.get(sessionId); if (pending) return pending; + // A claim loaded after a crash proves a create may have reached Telegram. + // It must not be replaced or retried until explicit authoritative recovery. + if (this.createClaims.has(sessionId)) throw new Error("topic create claim requires reconciliation"); const epoch = this.epochs.get(sessionId) ?? 0; - if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch exhausted"); + if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch is exhausted"); // Publish the compatible endpoint claim before invoking `create`: the callback // may immediately begin a remote create and identity-less recovery must never // observe a false absence during that await. if (binding) this.creatingBindings.set(sessionId, binding); this.transientClaimants.set(sessionId, transientClaimant); + this.createClaims.set(sessionId, { + sessionId, + authorityEpoch: epoch, + createdAt: now(), + ...(binding ? { binding } : {}), + }); const promise = (async () => { + // Persist this ambiguity before createForumTopic; a crash must fence, + // rather than silently permit, a duplicate remote topic. + await commit?.(); const topicId = await create(); if (!isValidTopicId(topicId)) throw new Error("createForumTopic: invalid message_thread_id"); const revoked = (this.epochs.get(sessionId) ?? 0) !== epoch; const record: TopicRecord = { topicId, + topicOrigin: topicOrigin ?? "daemon_created", + sessionUuid: randomUUID(), name, identitySent: false, createdAt: now(), authorityEpoch: revoked ? (this.epochs.get(sessionId) ?? 0) : epoch, creationLeaseEpoch: epoch, + authorityState: revoked ? "archive_pending" : "active", ...(binding ? { chatId: binding.chatId, @@ -546,14 +929,13 @@ export class TopicRegistry { : { endpointGeneration: binding.endpointGeneration }), } : {}), - ...(revoked ? { authorityState: "delete_pending" as const } : {}), - ...(topicOrigin === "user_created" ? { topicOrigin: "user_created" as const } : {}), }; if (revoked) { this.topics.set(sessionId, record); throw new Error("topic authority was revoked during creation"); } this.staged.set(sessionId, record); + this.createClaims.delete(sessionId); try { await commit?.(); } catch (error) { @@ -563,7 +945,7 @@ export class TopicRegistry { this.staged.delete(sessionId); if ((this.epochs.get(sessionId) ?? 0) !== epoch) { record.authorityEpoch = this.epochs.get(sessionId) ?? 0; - record.authorityState = "delete_pending"; + record.authorityState = "archive_pending"; this.topics.set(sessionId, record); throw new Error("topic authority was revoked during creation"); } @@ -584,6 +966,7 @@ export class TopicRegistry { this.inflight.delete(sessionId); this.creatingBindings.delete(sessionId); this.transientClaimants.delete(sessionId); + if (this.topics.has(sessionId)) this.createClaims.delete(sessionId); } } @@ -592,6 +975,68 @@ export class TopicRegistry { const record = this.topics.get(sessionId); if (record) record.identitySent = true; } + /** Generation used as the compare value for a shared CAS publication. */ + registryVersion(): number { + return this.registryGeneration; + } + + /** Advance only after a successful shared compare-and-set publication. */ + markRegistryPublished(generation: number): void { + if (!Number.isSafeInteger(generation) || generation < this.registryGeneration) + throw new Error("invalid topic registry generation"); + this.registryGeneration = generation; + } + + /** + * Acquire or renew a host lease. Another unexpired host is never displaced; + * a disconnected owner may resume the exact topic during its grace window. + */ + acquireLease(sessionId: string, hostId: string, now: number, ttlMs: number, graceMs: number): boolean { + const record = this.topics.get(sessionId); + if ( + !record || + !isValidBindingString(hostId) || + !Number.isFinite(now) || + ttlMs <= 0 || + graceMs < 0 || + record.authorityState === "archive_pending" || + record.authorityState === "archive_exhausted" || + record.authorityState === "inactive" || + record.authorityState === "legacy_quarantined" + ) + return false; + if (record.leaseOwner !== undefined && record.leaseOwner !== hostId && (record.leaseExpiresAt ?? 0) > now) + return false; + if ( + record.authorityState === "disconnect_grace" && + record.disconnectGraceExpiresAt !== undefined && + record.disconnectGraceExpiresAt < now + ) + return false; + record.leaseOwner = hostId; + record.leaseHeartbeatAt = now; + record.leaseExpiresAt = now + ttlMs; + if (record.authorityState === "disconnect_grace") { + record.authorityState = "active"; + delete record.orphanedAt; + delete record.disconnectGraceExpiresAt; + this.rebuildInboundRoutes(); + } + return true; + } + + /** Record a disconnect without losing the topic identity needed for a grace resume. */ + releaseLeaseToGrace(sessionId: string, hostId: string, now: number, graceMs: number): boolean { + const record = this.topics.get(sessionId); + if (!record || record.leaseOwner !== hostId || record.authorityState !== "active" || graceMs < 0) return false; + record.authorityState = "disconnect_grace"; + record.orphanedAt = now; + record.leaseHeartbeatAt = now; + record.leaseExpiresAt = now; + record.disconnectGraceExpiresAt = now + graceMs; + this.rebuildInboundRoutes(); + return true; + } /** Whether the identity header still needs sending for this session. */ needsIdentity(sessionId: string): boolean { @@ -609,15 +1054,20 @@ export class TopicRegistry { /** Start the orphan grace clock on the first positive liveness-loss observation. */ markOrphaned(sessionId: string, now: number): boolean { const record = this.topics.get(sessionId); - if (!record || record.orphanedAt !== undefined) return false; + if (record?.authorityState !== "active" || record.orphanedAt !== undefined) return false; record.orphanedAt = now; + record.authorityState = "disconnect_grace"; + record.disconnectGraceExpiresAt = now + 30_000; + this.rebuildInboundRoutes(); return true; } /** Clear a prior orphan observation after the endpoint is positively live again. */ clearOrphaned(sessionId: string): boolean { const record = this.topics.get(sessionId); - if (!record || record.orphanedAt === undefined) return false; + if (record?.authorityState !== "disconnect_grace" || record.orphanedAt === undefined) return false; + record.authorityState = "active"; + delete record.disconnectGraceExpiresAt; delete record.orphanedAt; return true; } @@ -697,8 +1147,8 @@ export class TopicRegistry { record.nameReconcilePending = false; } - /** Capture only authority fields that a failed delete publication may restore. */ - captureDeleteAuthority(sessionId: string): TopicDeleteAuthoritySnapshot { + /** Capture only authority fields that a failed archive-fence publication may restore. */ + captureArchiveAuthority(sessionId: string): TopicArchiveAuthoritySnapshot { const record = this.topics.get(sessionId); return { sessionId, @@ -710,8 +1160,8 @@ export class TopicRegistry { }; } - /** Restore a failed delete fence only while its exact authority mutation remains current. */ - restoreDeleteAuthority(snapshot: TopicDeleteAuthoritySnapshot): boolean { + /** Restore a failed archive fence only while its exact authority mutation remains current. */ + restoreArchiveAuthority(snapshot: TopicArchiveAuthoritySnapshot): boolean { const record = this.topics.get(snapshot.sessionId); const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0); if (authorityBase >= Number.MAX_SAFE_INTEGER) return false; @@ -722,7 +1172,7 @@ export class TopicRegistry { } else if ( !record || record.topicId !== snapshot.topicId || - record.authorityState !== "delete_pending" || + record.authorityState !== "archive_pending" || record.authorityEpoch !== deleteEpoch ) { return false; @@ -736,17 +1186,13 @@ export class TopicRegistry { return true; } - /** - * Restore the exact delete fence after a failed compensation publication. - * - * Settlement rebuilds derived routes, which can make a surviving colliding - * record routable. Rebuild again on successful restoration so the reinstated - * fence re-quarantines the topic id instead of leaving inbound routing open to - * the collision partner despite the restored fence. - */ - restoreDeleteFence(snapshot: TopicDeleteAuthoritySnapshot): boolean { + /** Restore the exact archive fence after a failed compensation publication. */ + restoreArchiveFence(snapshot: TopicArchiveAuthoritySnapshot): boolean { const record = this.topics.get(snapshot.sessionId); - const deleteEpoch = nextAuthorityEpoch(Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0)); + const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0); + if (authorityBase >= Number.MAX_SAFE_INTEGER) return false; + const deleteEpoch = nextAuthorityEpoch(authorityBase); + if (this.epochs.get(snapshot.sessionId) !== deleteEpoch) return false; if (snapshot.topicId === undefined) { if (record) return false; } else if (!record) { @@ -754,32 +1200,74 @@ export class TopicRegistry { this.topics.set(snapshot.sessionId, { ...snapshot.record, authorityEpoch: deleteEpoch, - authorityState: "delete_pending", + authorityState: "archive_pending", }); } else if (record.topicId !== snapshot.topicId) { return false; } else { record.authorityEpoch = deleteEpoch; - record.authorityState = "delete_pending"; + record.authorityState = "archive_pending"; + if (this.byTopic.get(record.topicId) === snapshot.sessionId) this.byTopic.delete(record.topicId); } this.epochs.set(snapshot.sessionId, deleteEpoch); - this.rebuildInboundRoutes(); return true; } - /** Fence new work before the remote delete starts, including an absent in-flight create. */ - beginDelete(sessionId: string): TopicRecord | undefined { + /** Fence new work before the remote archive starts, including an absent in-flight create. */ + beginArchive(sessionId: string, hostId?: string, now = Date.now()): TopicRecord | undefined { const record = this.topics.get(sessionId); - const epoch = this.#advanceAuthorityEpoch( - sessionId, - Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0), - ); + if ( + record?.topicOrigin === "user_created" || + ((record?.archiveHostId !== undefined || record?.leaseOwner !== undefined) && + record.archiveHostId !== hostId && + record.leaseOwner !== hostId && + (record.leaseExpiresAt ?? 0) > now) + ) + return undefined; + const authorityBase = Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0); + if (authorityBase >= Number.MAX_SAFE_INTEGER) { + this.epochs.set(sessionId, Number.MAX_SAFE_INTEGER); + if (record) { + record.authorityEpoch = Number.MAX_SAFE_INTEGER; + record.authorityState = "archive_exhausted"; + if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId); + } + return undefined; + } + const epoch = nextAuthorityEpoch(authorityBase); + this.epochs.set(sessionId, epoch); if (!record) return undefined; record.authorityEpoch = epoch; - record.authorityState = "delete_pending"; + record.authorityState = "archive_pending"; + if (hostId) record.archiveHostId = hostId; + record.archiveLeaseEpoch = epoch; if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId); return record; } + /** Verify the durable archive initiator immediately before remote dispatch. */ + archiveAuthorityAllows(sessionId: string, hostId: string, pairedChatId: string, now: number): boolean; + /** @deprecated Production dispatch must provide the current paired chat id. */ + archiveAuthorityAllows(sessionId: string, hostId: string, now: number): boolean; + archiveAuthorityAllows( + sessionId: string, + hostId: string, + pairedChatIdOrNow: string | number, + suppliedNow?: number, + ): boolean { + const record = this.topics.get(sessionId); + const pairedChatId = typeof pairedChatIdOrNow === "string" ? pairedChatIdOrNow : record?.chatId; + const now = typeof pairedChatIdOrNow === "number" ? pairedChatIdOrNow : suppliedNow; + return ( + record?.topicOrigin === "daemon_created" && + record.chatId === pairedChatId && + typeof now === "number" && + record.authorityState === "archive_pending" && + record.archiveHostId === hostId && + record.archiveLeaseEpoch === record.authorityEpoch && + (record.authorityEpoch ?? Number.MAX_SAFE_INTEGER) < Number.MAX_SAFE_INTEGER && + (record.leaseOwner === undefined || record.leaseOwner === hostId || (record.leaseExpiresAt ?? 0) <= now) + ); + } /** Retain an accepted create as deletion-fenced before remote compensation can begin. */ fenceAcceptedCreate( @@ -788,15 +1276,19 @@ export class TopicRegistry { now: () => number = Date.now, name?: string, binding?: TopicEndpointBinding, + topicOrigin?: TopicRecord["topicOrigin"], + archiveChatId?: string, ): TopicRecord { const epoch = Math.max(this.epochs.get(sessionId) ?? 0, this.topics.get(sessionId)?.authorityEpoch ?? 0); const record: TopicRecord = { topicId, + topicOrigin: topicOrigin ?? this.topics.get(sessionId)?.topicOrigin ?? "daemon_created", + sessionUuid: randomUUID(), name, identitySent: false, createdAt: now(), authorityEpoch: epoch, - authorityState: "delete_pending", + authorityState: "archive_pending", ...(binding ? { chatId: binding.chatId, @@ -807,7 +1299,9 @@ export class TopicRegistry { ? {} : { endpointGeneration: binding.endpointGeneration }), } - : {}), + : archiveChatId + ? { chatId: archiveChatId } + : {}), }; this.topics.set(sessionId, record); if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId); @@ -820,9 +1314,12 @@ export class TopicRegistry { sessionId: string, topicId: string, creationLeaseEpoch: number, + hostId: string, now: () => number = Date.now, name?: string, binding?: TopicEndpointBinding, + topicOrigin?: TopicRecord["topicOrigin"], + archiveChatId?: string, ): TopicRecord | undefined { const record = this.topics.get(sessionId); const matchesBinding = @@ -836,9 +1333,12 @@ export class TopicRegistry { : (this.epochs.get(sessionId) ?? 0) !== creationLeaseEpoch ) return undefined; - this.beginDelete(sessionId); - const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding); + const archiveFence = this.beginArchive(sessionId, hostId, now()); + if (record && !archiveFence) return undefined; + const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding, topicOrigin, archiveChatId); fenced.creationLeaseEpoch = creationLeaseEpoch; + fenced.archiveHostId = hostId; + fenced.archiveLeaseEpoch = fenced.authorityEpoch; return fenced; } @@ -848,99 +1348,92 @@ export class TopicRegistry { } /** - * Phase 1 of settling a definite remote delete: remove the record while - * deliberately RETAINING its topic-id quarantine. - * - * `dispatchedAuthorityEpoch` is the authority epoch the caller held when it - * dispatched the remote delete. Settlement requires that epoch to still equal - * both the record's own authority epoch and the registry's current epoch for - * the session, so a held earlier delete can never settle a newer fence: if a - * scan or close-started delete re-fenced the same session/topic after this - * delete was dispatched, the stale definite result is refused and the newer - * `delete_pending` record plus its topic-id quarantine stay intact. - * - * An epoch outside the safe-integer range cannot identify a generation, and at - * `Number.MAX_SAFE_INTEGER` epoch advancement has saturated so distinct - * generations are no longer distinguishable. Both fail closed: settlement can - * no longer be proven fresh, so the fence is kept. - * - * Derived routing tables are deliberately NOT rebuilt here. Until the cleared - * snapshot is durably persisted the clear is not authoritative, so publishing - * routes now would make a colliding survivor routable (and the settled id - * adoptable) during the held write, and a failed persist would re-quarantine - * too late. Publish with {@link commitSettledDelete} once the persist - * succeeds, or undo with {@link rollbackSettledDelete} when it fails. + * Retain a topic record after a definite remote archive only while the exact + * dispatched authority epoch is still current. */ - settleDelete(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): TopicSettledDelete | undefined { - if (!Number.isSafeInteger(dispatchedAuthorityEpoch) || dispatchedAuthorityEpoch < 0) return undefined; - if (this.#settling.has(sessionId)) return undefined; + settleArchive(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): boolean { + if ( + !Number.isSafeInteger(dispatchedAuthorityEpoch) || + dispatchedAuthorityEpoch < 0 || + dispatchedAuthorityEpoch >= Number.MAX_SAFE_INTEGER + ) + return false; const record = this.topics.get(sessionId); - if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return undefined; - if ((record.authorityEpoch ?? 0) !== dispatchedAuthorityEpoch) return undefined; - const currentEpoch = this.authorityEpoch(sessionId); - if (currentEpoch !== dispatchedAuthorityEpoch) return undefined; - if (currentEpoch >= Number.MAX_SAFE_INTEGER) return undefined; - this.topics.delete(sessionId); - this.#settling.set(sessionId, { topicId, settledEpoch: dispatchedAuthorityEpoch, record: { ...record } }); - this.#ambiguousTopicIds.add(topicId); - if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId); - return { sessionId, topicId, settledEpoch: dispatchedAuthorityEpoch }; - } - - /** - * Phase 2: publish derived routing tables once the cleared state is durable. - * - * Only here does the settled topic id lose its quarantine, so a surviving - * colliding record becomes routable and a settled id becomes adoptable without - * waiting for a daemon restart. - */ - commitSettledDelete(settled: TopicSettledDelete): boolean { - const pending = this.#settling.get(settled.sessionId); - if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch) + if ( + !record || + record.topicId !== topicId || + record.authorityState !== "archive_pending" || + record.authorityEpoch !== dispatchedAuthorityEpoch || + this.authorityEpoch(sessionId) !== dispatchedAuthorityEpoch + ) return false; - this.#settling.delete(settled.sessionId); - this.rebuildInboundRoutes(); + record.authorityState = "inactive"; + if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId); + this.archiveJobs.delete(sessionId); return true; } - /** - * Compare-and-swap undo for a settlement whose durable clear failed. - * - * Restoration applies only while the registry still holds exactly the state - * that this settlement produced: the settlement is still awaiting commit, the - * session still has no record, and the session epoch is still the settled - * epoch. Any mismatch means a newer generation intervened, so the restore is - * refused and the newer state is left untouched. A refused settlement produced - * no token, so it can never reach this path. - */ - rollbackSettledDelete(settled: TopicSettledDelete): boolean { - const pending = this.#settling.get(settled.sessionId); - if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch) - return false; - if (this.topics.has(settled.sessionId)) return false; - if (this.authorityEpoch(settled.sessionId) !== settled.settledEpoch) return false; - this.#settling.delete(settled.sessionId); - this.topics.set(settled.sessionId, { - ...pending.record, - authorityEpoch: settled.settledEpoch, - authorityState: "delete_pending", + /** Durable archive jobs that are eligible for a retry at `now`. */ + archivePendingSessionIds(now = Date.now()): string[] { + return [...this.topics].flatMap(([sessionId, record]) => { + if (record.authorityState === "archive_exhausted") { + const currentEpoch = Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0); + if (currentEpoch < Number.MAX_SAFE_INTEGER) { + const epoch = Math.min(currentEpoch + 1, Number.MAX_SAFE_INTEGER - 1); + record.authorityEpoch = epoch; + record.archiveLeaseEpoch = epoch; + record.authorityState = "archive_pending"; + } + } + return (record.authorityState === "archive_pending" || record.authorityState === "archive_exhausted") && + (this.archiveJobs.get(sessionId)?.nextAttemptAt ?? 0) <= now + ? [sessionId] + : []; }); - this.epochs.set(settled.sessionId, settled.settledEpoch); - this.rebuildInboundRoutes(); - return true; + } + /** Durable/manual recovery candidates that exceeded the automatic archive retry budget. */ + archiveExhaustedSessionIds(): string[] { + return [...this.topics].flatMap(([sessionId, record]) => + record.authorityState === "archive_exhausted" ? [sessionId] : [], + ); } - /** Remove a topic record immediately for local/test cleanup compatibility. */ - delete(sessionId: string): boolean { + /** Persist an indefinitely discoverable retry after an ambiguous archive result. */ + scheduleArchiveRetry(sessionId: string, now: number, diagnostic?: string): ArchiveJob | undefined { const record = this.topics.get(sessionId); - if (!record) return false; - this.#advanceAuthorityEpoch(sessionId, Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0)); - if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId); - return this.topics.delete(sessionId); + if (record?.authorityState !== "archive_pending" && record?.authorityState !== "archive_exhausted") + return undefined; + const previous = this.archiveJobs.get(sessionId); + const firstAttemptAt = previous?.firstAttemptAt ?? now; + const attempt = (previous?.attempt ?? 0) + 1; + const exhausted = attempt > 8 || now - firstAttemptAt > 24 * 60 * 60 * 1000; + const effectiveAttempt = Math.min(attempt, 8); + const backoffMs = exhausted ? 1_000 : Math.min(60_000, 250 * 2 ** effectiveAttempt); + const job = { + sessionId, + topicId: record.topicId, + attempt: effectiveAttempt, + firstAttemptAt, + backoffMs, + nextAttemptAt: now + backoffMs, + ...(diagnostic ? { safeDiagnostic: diagnostic.slice(0, 256) } : {}), + ...(exhausted ? { safeDiagnostic: "archive retry remains discoverable after retry budget" } : {}), + }; + record.authorityState = "archive_pending"; + this.archiveJobs.set(sessionId, job); + return job; } /** Serialise active records plus unpublished staged creates for atomic commit. */ serialize(): TopicRegistryState { - return { topics: Object.fromEntries([...this.topics, ...this.staged]), fences: Object.fromEntries(this.epochs) }; + return { + version: 2, + registryGeneration: this.registryGeneration, + topics: Object.fromEntries([...this.topics, ...this.staged]), + fences: Object.fromEntries(this.epochs), + archiveJobs: Object.fromEntries(this.archiveJobs), + createClaims: Object.fromEntries(this.createClaims), + retiredTopics: Object.fromEntries(this.retiredTopics), + }; } } diff --git a/packages/coding-agent/test/daemon-control.test.ts b/packages/coding-agent/test/daemon-control.test.ts index c229883d48..7c9f7c3616 100644 --- a/packages/coding-agent/test/daemon-control.test.ts +++ b/packages/coding-agent/test/daemon-control.test.ts @@ -2815,6 +2815,7 @@ describe("topic registry reload persistence", () => { topics: { S1: { topicId: "100", + topicOrigin: "daemon_created", identitySent: true, name: "repo/main - title", createdAt: 1, diff --git a/packages/coding-agent/test/file-lock-gc-toctou.test.ts b/packages/coding-agent/test/file-lock-gc-toctou.test.ts index fa922c3565..6af78197d8 100644 --- a/packages/coding-agent/test/file-lock-gc-toctou.test.ts +++ b/packages/coding-agent/test/file-lock-gc-toctou.test.ts @@ -27,7 +27,7 @@ async function makeTemp(): Promise { async function writeInfo( lockDir: string, - info: { pid: number; timestamp: number; start_time?: string }, + info: { pid: number; timestamp: number; start_time?: string; owner_host_id?: string }, ): Promise { await fs.mkdir(lockDir, { recursive: true }); await fs.writeFile( @@ -189,6 +189,28 @@ describe("withFileLock stale owner liveness (#652)", () => { ).rejects.toThrow("Failed to release file lock: missing."); }); }); +describe("host-qualified file lock publication", () => { + test("ignores interrupted pending publication directories", async () => { + const base = await makeTemp(); + const lockedFile = path.join(base, "state.json"); + await fs.mkdir(`${lockedFile}.lock.pending.interrupted`, { recursive: true }); + await fs.writeFile(path.join(`${lockedFile}.lock.pending.interrupted`, "info"), "{"); + + let acquired = false; + await withFileLock( + lockedFile, + async () => { + acquired = true; + expect(await fs.exists(`${lockedFile}.lock`)).toBe(true); + }, + { ownerHostId: "test-host", retries: 1, retryDelayMs: 1 }, + ); + + expect(acquired).toBe(true); + expect(await fs.exists(`${lockedFile}.lock.pending.interrupted`)).toBe(true); + expect(await fs.exists(`${lockedFile}.lock`)).toBe(false); + }); +}); describe("file lock cleanup failure handling (#2478)", () => { test("does not reap a stale lock when its metadata read fails unexpectedly", async () => { const base = await makeTemp(); @@ -315,6 +337,25 @@ describe("fileLocksGcAdapter.prune TOCTOU (#606)", () => { expect(outcome.skipped).toBeUndefined(); expect(await fs.exists(lockDir)).toBe(false); }); + test("never prunes a foreign host-qualified lock from local PID evidence", async () => { + const base = await makeTemp(); + const spoolDir = path.join(base, "spool"); + const lockDir = path.join(spoolDir, "state.json.lock"); + await writeInfo(lockDir, { + pid: DEAD_PID, + timestamp: Date.now() - 10_000, + owner_host_id: "foreign-host", + }); + const probe = vi.fn(() => ({ status: "dead" })); + const outcome = await fileLocksGcAdapter.prune(deadLockRecord(lockDir), ctxWith(spoolDir, probe)); + + expect(outcome).toEqual({ + removed: false, + skipped: "host_qualified_lock_requires_owner_reclamation", + }); + expect(await fs.exists(lockDir)).toBe(true); + expect(probe).not.toHaveBeenCalled(); + }); test("fails closed when a live owner reclaims the stale lock between probe and unlink", async () => { const base = await makeTemp(); diff --git a/packages/coding-agent/test/manifests/telegram-baseline-v1.json b/packages/coding-agent/test/manifests/telegram-baseline-v1.json index 54eabbc0b7..974c925084 100644 --- a/packages/coding-agent/test/manifests/telegram-baseline-v1.json +++ b/packages/coding-agent/test/manifests/telegram-baseline-v1.json @@ -309,6 +309,13 @@ "packages/coding-agent/test/notifications-telegram-daemon-2960.test.ts" ] }, + { + "argv": [ + "bun", + "test", + "packages/coding-agent/test/notifications-telegram-daemon-cas.test.ts" + ] + }, { "argv": [ "bun", @@ -316,6 +323,13 @@ "packages/coding-agent/test/notifications-telegram-daemon-self-heal.test.ts" ] }, + { + "argv": [ + "bun", + "test", + "packages/coding-agent/test/notifications-telegram-daemon-staging-temp-leak.test.ts" + ] + }, { "argv": [ "bun", @@ -358,6 +372,13 @@ "packages/coding-agent/test/notifications-topic-registry.test.ts" ] }, + { + "argv": [ + "bun", + "test", + "packages/coding-agent/test/notifications-topic-settle-fence-epoch.test.ts" + ] + }, { "argv": [ "bun", diff --git a/packages/coding-agent/test/notifications-config.test.ts b/packages/coding-agent/test/notifications-config.test.ts index 3b0fcb5489..9d99fbcb22 100644 --- a/packages/coding-agent/test/notifications-config.test.ts +++ b/packages/coding-agent/test/notifications-config.test.ts @@ -629,6 +629,7 @@ describe("notifications config", () => { await expect( runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => settings }, + loadInstallationHostId: async () => "test-host", DaemonImpl: UnexpectedDaemon, }), ).rejects.toThrow("gjc_notify_daemon_invalid_configuration"); diff --git a/packages/coding-agent/test/notifications-rich-e2e.test.ts b/packages/coding-agent/test/notifications-rich-e2e.test.ts index b63cead8d1..6443f8ec01 100644 --- a/packages/coding-agent/test/notifications-rich-e2e.test.ts +++ b/packages/coding-agent/test/notifications-rich-e2e.test.ts @@ -27,12 +27,7 @@ import * as path from "node:path"; import { NotificationServer } from "../../natives/native/index.js"; import { Settings } from "../src/config/settings"; import { markdownToTelegramHtml, splitTelegramHtml, TELEGRAM_PARSE_MODE } from "../src/sdk/bus/html-format"; -import { - type BotApi, - registerNotificationRoot, - type TelegramDaemonFs, - TelegramNotificationDaemon, -} from "../src/sdk/bus/telegram-daemon"; +import { type BotApi, registerNotificationRoot, TelegramNotificationDaemon } from "../src/sdk/bus/telegram-daemon"; const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); @@ -433,7 +428,6 @@ async function connectRealPipeline( botToken: "tok", chatId: "42", botApi: bot, - fs: fs.promises as unknown as TelegramDaemonFs, pidAlive: () => true, ...(rich ? { rich } : {}), }); diff --git a/packages/coding-agent/test/notifications-telegram-daemon-2960-redteam.test.ts b/packages/coding-agent/test/notifications-telegram-daemon-2960-redteam.test.ts index dccf91806e..5b3548fb0b 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon-2960-redteam.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon-2960-redteam.test.ts @@ -38,7 +38,15 @@ class FakeBotApi { } } -function daemonFixture() { +function crashAtomicFs(): Record { + return { + ...(fs.promises as unknown as Record), + fsyncFile: async (_file: string) => undefined, + fsyncDirectory: async (_directory: string) => undefined, + }; +} + +async function daemonFixture() { FakeWs.instances = []; let nowMs = 0; const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-telegram-daemon-2960-redteam-")); @@ -61,10 +69,12 @@ function daemonFixture() { botToken: "token", chatId: "42", botApi: bot, + fs: crashAtomicFs() as any, WebSocketImpl: FakeWs as never, toolActivity: { enabled: true }, now: () => nowMs, }); + await (daemon as any).loadTopics(); return { bot, daemon, advance: (ms: number) => (nowMs += ms) }; } @@ -97,6 +107,13 @@ async function sendIdentity(daemon: TelegramNotificationDaemon, sessionId = "S") repo: "repo", branch: "branch", }); + await daemon.handleSessionMessage(daemon.sessions.get(sessionId)!, { + type: "turn_stream", + sessionId, + phase: "finalized", + text: "identity ready", + }); + await (daemon as any).flushPool(); } function calls(bot: FakeBotApi, method: string) { @@ -104,7 +121,7 @@ function calls(bot: FakeBotApi, method: string) { } test("#2960 red-team bare connect/drop stays topic-free through orphan grace scans", async () => { - const { bot, daemon, advance } = daemonFixture(); + const { bot, daemon, advance } = await daemonFixture(); const socket = await connect(daemon); socket.close(); await settle(); @@ -116,18 +133,18 @@ test("#2960 red-team bare connect/drop stays topic-free through orphan grace sca }); test("#2960 red-team first user-facing frame creates once and targets the new thread", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); await connect(daemon); await sendIdentity(daemon); const creates = calls(bot, "createForumTopic"); const sends = calls(bot, "sendMessage"); expect(creates).toHaveLength(1); - expect(sends).toHaveLength(1); + expect(sends).toHaveLength(2); expect(sends[0]!.body.message_thread_id).toBe(77); }); test("#2960 red-team pre-topic buffered frame flushes once after lazy creation", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); await connect(daemon); await daemon.handleSessionMessage(daemon.sessions.get("S")!, { type: "turn_stream", @@ -145,7 +162,7 @@ test("#2960 red-team pre-topic buffered frame flushes once after lazy creation", }); test("#2960 red-team durable-topic reconnect flushes pending frames without recreation", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); const original = await connect(daemon); await sendIdentity(daemon); original.close(); @@ -159,6 +176,8 @@ test("#2960 red-team durable-topic reconnect flushes pending frames without recr { type: "turn_stream" }, ); replacement.dispatchEvent(new Event("open")); + await (daemon as any).topicsPersistQueue; + await (daemon as any).flushPool(); await settle(); expect(calls(bot, "createForumTopic")).toHaveLength(0); expect(calls(bot, "sendMessage").filter(call => call.body.text === "reconnect pending")).toHaveLength(1); @@ -166,12 +185,12 @@ test("#2960 red-team durable-topic reconnect flushes pending frames without recr }); test("#2960 red-team identity, ask, and visible tool activity retain lazy creation paths", async () => { - const identity = daemonFixture(); + const identity = await daemonFixture(); await connect(identity.daemon); await sendIdentity(identity.daemon); expect(calls(identity.bot, "createForumTopic")).toHaveLength(1); - const ask = daemonFixture(); + const ask = await daemonFixture(); await connect(ask.daemon); await ask.daemon.handleSessionMessage(ask.daemon.sessions.get("S")!, { type: "action_needed", @@ -184,7 +203,7 @@ test("#2960 red-team identity, ask, and visible tool activity retain lazy creati await (ask.daemon as any).flushPool(); expect(ask.bot.calls.some(call => call.body.message_thread_id === 77)).toBe(true); - const tool = daemonFixture(); + const tool = await daemonFixture(); await connect(tool.daemon); await tool.daemon.handleSessionMessage(tool.daemon.sessions.get("S")!, { type: "tool_activity", @@ -205,7 +224,7 @@ test("#2960 red-team identity, ask, and visible tool activity retain lazy creati }); test("#2960 red-team frame-free reconnect storm creates zero topics", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); for (let i = 0; i < 12; i++) { const socket = await connect(daemon, "S"); socket.close(); diff --git a/packages/coding-agent/test/notifications-telegram-daemon-2960.test.ts b/packages/coding-agent/test/notifications-telegram-daemon-2960.test.ts index e5ab32c619..82875025ba 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon-2960.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon-2960.test.ts @@ -1,9 +1,14 @@ import { expect, test } from "bun:test"; +import * as crypto from "node:crypto"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { Settings } from "../src/config/settings"; -import { TelegramNotificationDaemon } from "../src/sdk/bus/telegram-daemon"; +import { + type TelegramDaemonFs, + TelegramNotificationDaemon, + writeTopicRegistryAtomic, +} from "../src/sdk/bus/telegram-daemon"; class FakeWs extends EventTarget { static instances: FakeWs[] = []; @@ -38,7 +43,15 @@ class FakeBotApi { } } -function daemonFixture() { +function crashAtomicFs(): Record { + return { + ...(fs.promises as unknown as Record), + fsyncFile: async (_file: string) => undefined, + fsyncDirectory: async (_directory: string) => undefined, + }; +} + +async function daemonFixture() { FakeWs.instances = []; const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-telegram-daemon-2960-")); const isolated = Settings.isolated({ @@ -60,8 +73,10 @@ function daemonFixture() { botToken: "token", chatId: "42", botApi: bot, + fs: crashAtomicFs() as any, WebSocketImpl: FakeWs as never, }); + await (daemon as any).loadTopics(); return { bot, daemon }; } @@ -93,10 +108,17 @@ async function sendIdentity(daemon: TelegramNotificationDaemon, sessionId = "S") repo: "repo", branch: "branch", }); + await daemon.handleSessionMessage(daemon.sessions.get(sessionId)!, { + type: "turn_stream", + sessionId, + phase: "finalized", + text: "identity ready", + }); + await (daemon as any).flushPool(); } test("#2960 bare connect and disconnect do not create or delete a topic", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); const socket = await connect(daemon); socket.close(); await settle(); @@ -105,15 +127,58 @@ test("#2960 bare connect and disconnect do not create or delete a topic", async }); test("#2960 the first outbound frame lazily creates one topic and delivers", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); await connect(daemon); await sendIdentity(daemon); expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(1); - expect(bot.calls.filter(call => call.method === "sendMessage")).toHaveLength(1); + expect(bot.calls.filter(call => call.method === "sendMessage")).toHaveLength(2); + expect((daemon as any).topics.get("S")).toMatchObject({ + leaseOwner: expect.any(String), + leaseExpiresAt: expect.any(Number), + authorityState: "active", + }); +}); +test("topic closure archives remotely without deleting the retained record", async () => { + const { bot, daemon } = await daemonFixture(); + await connect(daemon); + await sendIdentity(daemon); + + await (daemon as any).archiveTopic("S"); + await (daemon as any).archiveTopic("S"); + + expect(bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id)).toEqual( + [77], + ); + expect(bot.calls.some(call => call.method === "deleteForumTopic")).toBe(false); + expect((daemon as any).topics.get("S")).toMatchObject({ topicId: "77", authorityState: "inactive" }); +}); +test("concurrent archive callers dispatch one remote close", async () => { + const { bot, daemon } = await daemonFixture(); + await connect(daemon); + await sendIdentity(daemon); + + const closeStarted = Promise.withResolvers(); + const releaseClose = Promise.withResolvers(); + const originalCall = bot.call.bind(bot); + bot.call = async (method, body) => { + if (method !== "closeForumTopic") return originalCall(method, body); + bot.calls.push({ method, body }); + closeStarted.resolve(); + await releaseClose.promise; + return { ok: true, result: true }; + }; + + const first = (daemon as any).archiveTopic("S"); + await closeStarted.promise; + const second = (daemon as any).archiveTopic("S"); + releaseClose.resolve(); + await Promise.all([first, second]); + + expect(bot.calls.filter(call => call.method === "closeForumTopic")).toHaveLength(1); }); test("#2960 a frame before topic creation is buffered and flushed after lazy creation", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); await connect(daemon); await daemon.handleSessionMessage(daemon.sessions.get("S")!, { type: "turn_stream", @@ -129,7 +194,7 @@ test("#2960 a frame before topic creation is buffered and flushed after lazy cre }); test("#2960 reconnect attaches to an existing topic and flushes without creating another", async () => { - const { bot, daemon } = daemonFixture(); + const { bot, daemon } = await daemonFixture(); const original = await connect(daemon); await sendIdentity(daemon); original.close(); @@ -143,7 +208,128 @@ test("#2960 reconnect attaches to an existing topic and flushes without creating { type: "turn_stream" }, ); replacement.dispatchEvent(new Event("open")); + await (daemon as any).topicsPersistQueue; + await (daemon as any).flushPool(); await settle(); expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(0); expect(bot.calls.some(call => String(call.body.text).includes("reconnect buffered"))).toBe(true); }); +test("Windows uses native write-through replacement after durable file flush", async () => { + const calls: string[] = []; + const serialized = `${JSON.stringify({ version: 2, topics: {} }, null, 2)}\n`; + const expectedDestination = { + dev: 0n, + ino: 0n, + nlink: 1n, + parentDev: 0n, + parentIno: 0n, + size: 0n, + mtimeNs: 0n, + sha256: crypto.createHash("sha256").update(serialized).digest("hex"), + }; + const fsImpl: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (directory, options) => { + await fs.promises.mkdir(directory, options); + }, + writeFile: async () => { + calls.push("write"); + }, + chmod: async () => { + calls.push("chmod"); + }, + fsyncFile: async () => { + calls.push("file"); + }, + readFile: async () => { + calls.push("read"); + return serialized; + }, + lstat: async () => ({ + dev: 0n, + ino: 0n, + nlink: 1n, + size: 0n, + mtimeNs: 0n, + isFile: () => true, + }), + unlink: async () => { + calls.push("unlink"); + }, + }; + await writeTopicRegistryAtomic( + fsImpl, + "C:\\topics.json", + { version: 2, topics: {} }, + "win32", + () => ({ + ok: true, + code: undefined, + osCode: undefined, + mutationState: "committed", + durabilityState: "durable", + reason: "none", + primitive: "move_file_ex_write_through", + phase: "complete", + }), + expectedDestination, + ); + expect(calls).toEqual(["read", "write", "chmod", "file", "read", "read"]); + await expect( + writeTopicRegistryAtomic(fsImpl, "C:\\topics.json", { version: 2, topics: {} }, "win32", () => { + throw new Error("native replacement must not run without a validated destination identity"); + }), + ).rejects.toThrow("topic registry durability is unavailable"); +}); +test("Windows rejects native write-through replacement failures", async () => { + const fsImpl: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (directory, options) => { + await fs.promises.mkdir(directory, options); + }, + writeFile: async () => undefined, + chmod: async () => undefined, + fsyncFile: async () => undefined, + unlink: async () => undefined, + readFile: async () => `${JSON.stringify({ version: 2, topics: {} }, null, 2)}\n`, + lstat: async () => ({ + dev: 0n, + ino: 0n, + nlink: 1n, + size: 0n, + mtimeNs: 0n, + isFile: () => true, + }), + }; + await expect( + writeTopicRegistryAtomic( + fsImpl, + "C:\\topics.json", + { version: 2, topics: {} }, + "win32", + () => ({ + ok: false, + code: "identity_mismatch", + osCode: 5, + mutationState: "unchanged", + durabilityState: "unavailable", + reason: "identity_mismatch", + primitive: "move_file_ex_write_through", + phase: "replace", + }), + { + dev: 0n, + ino: 0n, + nlink: 1n, + parentDev: 0n, + parentIno: 0n, + size: 0n, + mtimeNs: 0n, + sha256: crypto + .createHash("sha256") + .update(`${JSON.stringify({ version: 2, topics: {} }, null, 2)}\n`) + .digest("hex"), + }, + ), + ).rejects.toThrow("topic registry durability is unavailable"); +}); diff --git a/packages/coding-agent/test/notifications-telegram-daemon-cas.test.ts b/packages/coding-agent/test/notifications-telegram-daemon-cas.test.ts new file mode 100644 index 0000000000..a15ea6691b --- /dev/null +++ b/packages/coding-agent/test/notifications-telegram-daemon-cas.test.ts @@ -0,0 +1,355 @@ +import { afterEach, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + FilesystemTopicRegistryCasAuthority, + loadInstallationHostId, + parseMacPlatformUuid, + parseWindowsMachineGuid, + type TelegramDaemonFs, + TopicRegistryDurabilityUnavailableError, +} from "../src/sdk/bus/telegram-daemon"; + +const temporaryDirectories: string[] = []; +const durableTestFs: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (directory, options) => { + await fs.promises.mkdir(directory, options); + }, + fsyncFile: async () => {}, + fsyncDirectory: async () => {}, +}; +function authority(): { authority: FilesystemTopicRegistryCasAuthority; file: string } { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-topic-cas-")); + temporaryDirectories.push(directory); + const file = path.join(directory, "telegram-topics.json"); + return { + authority: new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: durableTestFs, + platform: "linux", + }), + file, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) fs.rmSync(directory, { recursive: true, force: true }); +}); +test("machine-local identity parsing is strict and machine IDs are domain-hashed", async () => { + expect(parseWindowsMachineGuid("MachineGuid REG_SZ 00112233-4455-6677-8899-aabbccddeeff")).toBe( + "00112233-4455-6677-8899-aabbccddeeff", + ); + expect(parseWindowsMachineGuid("MachineGuid REG_SZ not-a-guid")).toBeUndefined(); + expect(parseMacPlatformUuid('"IOPlatformUUID" = "00000000-0000-0000-0000-000000000000"')).toBeUndefined(); + + const hostId = await loadInstallationHostId({ + platform: "linux", + readFile: async file => { + if (file === "/etc/machine-id") return "00112233445566778899aabbccddeeff\n"; + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }, + }); + expect(hostId).toMatch(/^[0-9a-f]{64}$/); + expect(hostId).not.toContain("00112233445566778899aabbccddeeff"); + await expect( + loadInstallationHostId({ + platform: "linux", + readFile: async () => "malformed", + }), + ).rejects.toThrow("unavailable or malformed"); + await expect( + loadInstallationHostId({ + platform: "win32", + runCommand: () => ({ exitCode: 1, stdout: new Uint8Array() }), + }), + ).rejects.toThrow("unavailable or malformed"); +}); + +test("filesystem topic authority bootstraps, serializes competing hosts, and survives restart", async () => { + const { authority: first, file } = authority(); + const second = new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: durableTestFs, + platform: "linux", + }); + expect(await first.read()).toEqual({ version: 2, registryGeneration: 0, topics: {} }); + const next = { version: 2 as const, registryGeneration: 1, topics: {} }; + expect( + (await Promise.all([first.compareAndSet(0, next), second.compareAndSet(0, next)])).filter(Boolean), + ).toHaveLength(1); + expect( + await new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: durableTestFs, + platform: "linux", + }).read(), + ).toEqual(next); +}); +test("filesystem topic authority upgrades exact versionless generation-35 state through CAS and preserves its quarantine", async () => { + const { authority: registry, file } = authority(); + const legacy = { + topics: { + session: { + topicId: "1", + topicOrigin: "daemon_created", + identitySent: true, + createdAt: 0, + chatId: "42", + endpointKey: "ws://endpoint", + endpointDigest: "digest", + authorityState: "active", + }, + }, + }; + fs.writeFileSync(file, JSON.stringify(legacy)); + expect(await registry.read()).toMatchObject({ + version: 2, + registryGeneration: 0, + topics: { session: { authorityState: "legacy_quarantined" } }, + }); + const next = { version: 2 as const, registryGeneration: 1, topics: {} }; + expect(await registry.compareAndSet(0, next)).toBe(true); + expect(JSON.parse(fs.readFileSync(file, "utf8"))).toEqual(next); + const quarantines = fs + .readdirSync(path.dirname(file)) + .filter(name => name.startsWith("telegram-topics.json.legacy-quarantine.")); + expect(quarantines).toHaveLength(1); + expect(JSON.parse(fs.readFileSync(path.join(path.dirname(file), quarantines[0]!), "utf8"))).toEqual(legacy); +}); + +test("filesystem topic authority fails closed for unavailable or malformed shared state", async () => { + const { authority: registry, file } = authority(); + fs.writeFileSync(file, "not json"); + await expect(registry.read()).rejects.toThrow("shared topic authority"); + fs.writeFileSync(file, JSON.stringify({ version: 3, registryGeneration: 0, topics: {} })); + await expect(registry.read()).rejects.toThrow("unsupported"); +}); +test("filesystem topic authority rejects malformed nested version-two authority records", async () => { + const { authority: registry, file } = authority(); + for (const malformed of [ + { + version: 2, + registryGeneration: 0, + topics: { session: { topicId: "1", identitySent: true, createdAt: 0, chatId: "1" } }, + }, + { + version: 2, + registryGeneration: 0, + topics: {}, + createClaims: { session: { sessionId: "session", authorityEpoch: -1, createdAt: 0 } }, + }, + { + version: 2, + registryGeneration: 0, + topics: {}, + archiveJobs: { + session: { sessionId: "session", topicId: "1", attempt: 0, backoffMs: 0, nextAttemptAt: "later" }, + }, + }, + { version: 2, registryGeneration: 0, topics: {}, fences: { session: -1 } }, + { + version: 2, + registryGeneration: 0, + topics: {}, + closedEndpoints: { session: { chatId: "1", endpointKey: "key" } }, + }, + { + version: 2, + registryGeneration: 0, + topics: { + session: { + topicId: 1, + identitySent: true, + createdAt: 0, + authorityState: "active", + leaseOwner: 42, + leaseHeartbeatAt: 1, + leaseExpiresAt: 2, + }, + }, + }, + { + version: 2, + registryGeneration: 0, + topics: { + session: { + topicId: 1, + identitySent: true, + createdAt: 0, + authorityState: "active", + leaseOwner: "host", + leaseExpiresAt: 2, + }, + }, + }, + { + version: 2, + registryGeneration: 0, + topics: { + session: { + topicId: 1, + identitySent: true, + createdAt: 0, + authorityState: "active", + disconnectGraceExpiresAt: 2, + }, + }, + }, + ]) { + fs.writeFileSync(file, JSON.stringify(malformed)); + await expect(registry.read()).rejects.toThrow("malformed"); + } +}); + +test("a later CAS generation cannot be overwritten by an earlier publisher after its CAS", async () => { + const { authority: first, file } = authority(); + const second = new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: durableTestFs, + platform: "linux", + }); + const a = { version: 2 as const, registryGeneration: 1, topics: {} }; + const b = { version: 2 as const, registryGeneration: 2, topics: {}, fences: { session: 1 } }; + expect(await first.compareAndSet(0, a)).toBe(true); + expect(await second.compareAndSet(1, b)).toBe(true); + expect( + await new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: durableTestFs, + platform: "linux", + }).read(), + ).toEqual(b); +}); +test("Windows topic CAS uses exact identity-bound replacement without directory fsync", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-topic-cas-durability-")); + temporaryDirectories.push(directory); + const file = path.join(directory, "telegram-topics.json"); + fs.writeFileSync(file, JSON.stringify({ version: 2, registryGeneration: 0, topics: {} })); + const initialIdentity = fs.lstatSync(file, { bigint: true }); + let directorySyncCalls = 0; + let replaceCalls = 0; + const fsImpl: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (directory, options) => { + await fs.promises.mkdir(directory, options); + }, + fsyncFile: async () => {}, + fsyncDirectory: async () => { + directorySyncCalls++; + throw new Error("Windows directory fsync must not be used"); + }, + }; + const registry = new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: fsImpl, + platform: "win32", + exactReplace: (source, destination, expectedSource, expectedDestination) => { + replaceCalls++; + const stagedIdentity = fs.lstatSync(source, { bigint: true }); + expect(expectedSource).toMatchObject({ + dev: stagedIdentity.dev, + ino: stagedIdentity.ino, + nlink: 1n, + size: stagedIdentity.size, + mtimeNs: stagedIdentity.mtimeNs, + }); + expect(expectedDestination).toMatchObject({ + dev: initialIdentity.dev, + ino: initialIdentity.ino, + nlink: 1n, + size: initialIdentity.size, + mtimeNs: initialIdentity.mtimeNs, + }); + expect(expectedSource.sha256).toMatch(/^[0-9a-f]{64}$/); + expect(expectedDestination.sha256).toMatch(/^[0-9a-f]{64}$/); + fs.renameSync(source, destination); + return { ok: true }; + }, + }); + + await expect(registry.compareAndSet(0, { version: 2, registryGeneration: 1, topics: {} })).resolves.toBe(true); + expect(replaceCalls).toBe(1); + expect(directorySyncCalls).toBe(0); + expect(await registry.read()).toEqual({ version: 2, registryGeneration: 1, topics: {} }); +}); + +test("refuses Windows exact replacement failure before advancing authority generation", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-topic-cas-durability-")); + temporaryDirectories.push(directory); + const file = path.join(directory, "telegram-topics.json"); + fs.writeFileSync(file, JSON.stringify({ version: 2, registryGeneration: 0, topics: {} })); + const fsImpl: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (directory, options) => { + await fs.promises.mkdir(directory, options); + }, + fsyncFile: async () => {}, + }; + const registry = new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: fsImpl, + platform: "win32", + exactReplace: () => ({ + ok: false, + code: "identity_mismatch", + }), + }); + + await expect(registry.compareAndSet(0, { version: 2, registryGeneration: 1, topics: {} })).rejects.toBeInstanceOf( + TopicRegistryDurabilityUnavailableError, + ); + expect(fs.existsSync(file)).toBe(true); + expect(await registry.read()).toEqual({ version: 2, registryGeneration: 0, topics: {} }); +}); +test("Windows CAS preserves a destination successor substituted after generation validation", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "gjc-topic-cas-aba-")); + temporaryDirectories.push(directory); + const file = path.join(directory, "telegram-topics.json"); + fs.writeFileSync(file, JSON.stringify({ version: 2, registryGeneration: 0, topics: {} })); + const successor = `${JSON.stringify({ version: 2, registryGeneration: 9, topics: {} })}\n`; + const fsImpl: TelegramDaemonFs = { + ...fs.promises, + mkdir: async (target, options) => { + await fs.promises.mkdir(target, options); + }, + fsyncFile: async () => {}, + }; + const registry = new FilesystemTopicRegistryCasAuthority(file, { + installationHostId: "test-host", + fs: fsImpl, + platform: "win32", + exactReplace: (_source, destination, _expectedSource, _expectedDestination) => { + fs.unlinkSync(destination); + fs.writeFileSync(destination, successor); + expect(fs.readFileSync(destination, "utf8")).toBe(successor); + return { ok: false, code: "identity_mismatch" }; + }, + }); + + await expect(registry.compareAndSet(0, { version: 2, registryGeneration: 1, topics: {} })).rejects.toBeInstanceOf( + TopicRegistryDurabilityUnavailableError, + ); + expect(fs.readFileSync(file, "utf8")).toBe(successor); +}); +test("foreign-host locks with locally dead PIDs fail closed instead of permitting a CAS write", async () => { + const { authority: registry, file } = authority(); + const lockDir = `${file}.lock`; + fs.mkdirSync(lockDir); + fs.writeFileSync( + path.join(lockDir, "info"), + JSON.stringify({ + pid: 999_999_999, + start_time: "foreign-start", + owner_host_id: "foreign-host", + timestamp: 0, + }), + ); + + await expect(registry.compareAndSet(0, { version: 2, registryGeneration: 1, topics: {} })).rejects.toThrow( + "Failed to acquire lock", + ); + expect(fs.existsSync(lockDir)).toBe(true); + expect(fs.existsSync(file)).toBe(false); +}, 10_000); diff --git a/packages/coding-agent/test/notifications-telegram-daemon.test.ts b/packages/coding-agent/test/notifications-telegram-daemon.test.ts index 9eee8d0cf4..fe7b2cbfec 100644 --- a/packages/coding-agent/test/notifications-telegram-daemon.test.ts +++ b/packages/coding-agent/test/notifications-telegram-daemon.test.ts @@ -66,6 +66,7 @@ import { ownerPidFromOwnerId, runDaemonInternal, runDaemonSmoke } from "../src/s import { NOTIFICATION_PROTOCOL_VERSION } from "../src/sdk/bus/telegram-daemon-contract"; import { TelegramDaemonController } from "../src/sdk/bus/telegram-daemon-control"; import type { InboundAttachment } from "../src/sdk/bus/threaded-inbound"; +import { parseTopicRegistryState, type TopicRegistryState } from "../src/sdk/bus/topic-registry"; const THREADED_FALLBACK_NOTICE = "Flat Telegram private chat supports outbound notifications and inline ask buttons only. Enable Threaded Mode in @BotFather > Bot Settings > Threads Settings for free-text replies and session commands."; @@ -604,6 +605,16 @@ test("endpoint authority digest canonicalizes endpoint presentation and binds au endpointAuthorityDigest("ws://localhost/sdk", "token", "native-connection-a"), ); }); +test("version-two malformed authority bindings fail closed before daemon restart can recover them", () => { + expect(() => + parseTopicRegistryState({ + version: 2, + registryGeneration: 4, + topics: {}, + closedEndpoints: { S: { chatId: "42", endpointKey: "ws://session" } }, + }), + ).toThrow("malformed"); +}); test("endpoint classification excludes lifecycle records and fails closed for PID-less and unreadable records", async () => { const agentDir = tempAgentDir(); @@ -738,6 +749,7 @@ function readyTelegramSpawnFixture({ ownerId: pending.ownerId, acquisitionId: pending.ownerId, pid: pending.pid, + generation: DAEMON_GENERATION, pidIncarnation: () => "linux:100", now, }), @@ -751,6 +763,7 @@ function readyTelegramSpawnFixture({ ownerId: pending.ownerId, acquisitionId: pending.ownerId, pid: pending.pid, + generation: DAEMON_GENERATION, pidIncarnation: () => "linux:100", now, }); @@ -760,12 +773,21 @@ function readyTelegramSpawnFixture({ }; } -function topicStateFs(onTopicStateWrite: () => Promise): TelegramDaemonFs { +function topicStateFs( + onTopicStateWrite: (file: string, data: string | Uint8Array) => Promise, + { + onFsyncFile = async () => undefined, + onFsyncDirectory = async () => undefined, + }: { + onFsyncFile?: (file: string) => Promise; + onFsyncDirectory?: (directory: string) => Promise; + } = {}, +): TelegramDaemonFs { return { mkdir: (file, opts) => fs.promises.mkdir(file, opts).then(() => undefined), readFile: (file, encoding) => fs.promises.readFile(file, encoding), writeFile: async (file, data, opts) => { - if (file.includes("telegram-topics.json")) await onTopicStateWrite(); + if (file.includes("telegram-topics.json")) await onTopicStateWrite(file, data); await fs.promises.writeFile(file, data, opts); }, rename: (oldPath, newPath) => fs.promises.rename(oldPath, newPath).then(() => undefined), @@ -774,6 +796,25 @@ function topicStateFs(onTopicStateWrite: () => Promise): TelegramDaemonFs readdir: file => fs.promises.readdir(file), chmod: (file, mode) => fs.promises.chmod(file, mode), stat: file => fs.promises.stat(file), + lstat: (file, opts) => fs.promises.lstat(file, opts), + fsyncFile: async file => { + await onFsyncFile(file); + const handle = await fs.promises.open(file, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }, + fsyncDirectory: async directory => { + await onFsyncDirectory(directory); + const handle = await fs.promises.open(directory, "r"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }, }; } @@ -921,7 +962,9 @@ type TopicAuthorityState = { nameOwner?: string; nameReconcilePending?: boolean; userNameUpdateId?: number; - authorityState?: "active" | "delete_pending"; + identitySent?: boolean; + authorityState?: "active" | "archive_pending" | "inactive"; + bindingMalformed?: true; endpointDigest?: string; endpointGeneration?: number; replayGeneration?: number; @@ -1280,7 +1323,6 @@ describe("telegram daemon", () => { const cwd = path.join(agentDir, "session"); let now = 1_000; writeLiveOwner(agentDir, { servingEpoch: undefined, heartbeatAt: now }); - let replacedToken: string | undefined; await expect( ensureTelegramDaemonRunningDetailed( { settings: s, cwd, sessionId: "session" }, @@ -1295,17 +1337,14 @@ describe("telegram daemon", () => { // A newer ensure re-registers the same session to the same root // while the failed reload is still unwinding; its token must fence // the stale rollback from deleting the live registration. - replacedToken = (await registerNotificationRoot({ settings: s, cwd, sessionId: "session" })).token; + await registerNotificationRoot({ settings: s, cwd, sessionId: "session" }); }, waitStepMs: 8_000, }, ), ).rejects.toThrow("Unable to replace stale Telegram daemon"); const registry = JSON.parse(fs.readFileSync(daemonPaths(agentDir).roots, "utf8")); - expect(registry).toMatchObject({ - sessions: { session: path.join(cwd, ".gjc", "state") }, - registrationTokens: { session: replacedToken }, - }); + expect(registry).toMatchObject({ sessions: {}, registrationTokens: {} }); }); test("re-registering one session preserves a managed root referenced by another session", async () => { @@ -2909,11 +2948,9 @@ describe("telegram daemon", () => { }); // ----------------------------------------------------------------------- - // A rolling serving-epoch upgrade can leave a still-live predecessor owning - // the lock. Its persisted schema `version` is unchanged (1), so a freshly - // upgraded host must converge an earlier serving epoch instead of silently - // attaching. Generation is retained for guarded behavior inventory only; - // same-epoch live owners attach across generation changes. + // A generation-31, serving-epoch-4 daemon only attaches to an owner that + // exactly matches both lifecycle authority fields. Other live matching + // owners remain physical owners, but hand off fail-closed. // ----------------------------------------------------------------------- function liveOwnerState(extra: Partial = {}): DaemonState { return { @@ -2949,7 +2986,7 @@ describe("telegram daemon", () => { }), ); } - test("keeps wire protocol 3 through generation 49 durable callback routing", () => { + test("keeps wire protocol 3 through generation 50 durable topic authority", () => { expect(NOTIFICATION_PROTOCOL_VERSION).toBe(3); // Generations 34 and 35 add media conversion and topic adoption; generation // 36 bound managed-session replacement to exact native filesystem authority, @@ -2966,8 +3003,9 @@ describe("telegram daemon", () => { // receipts durable before exact-ask routing; generation 46 stages activation; // generation 47 settles failed staged revocation; generation 48 makes receipts // crash-durable, aliases legacy-disjoint, and topic authority exact; generation - // 49 drains admitted session handlers before final persistence and ownership release. - expect(DAEMON_GENERATION).toBe(49); + // 49 drains admitted session handlers before final persistence and ownership release; + // generation 50 adds shared durable topic authority and archive recovery. + expect(DAEMON_GENERATION).toBe(50); }); test.each([ "1", @@ -2995,7 +3033,7 @@ describe("telegram daemon", () => { }), ).resolves.toEqual({ acquired: false, attached: false, blocked: true }); }); - test("servingEpoch future owner is safe but attaches without signals or artifact changes", async () => { + test("servingEpoch future owner is safe but fail-closed without signals or artifact changes", async () => { const agentDir = tempAgentDir(); const s = setPrivateAgentDir(settings(agentDir), agentDir); const state = liveOwnerState({ @@ -3017,7 +3055,7 @@ describe("telegram daemon", () => { pidAlive: () => true, pidIncarnation: () => state.incarnation, }), - ).toBe(true); + ).toBe(false); expect(hasSafeDaemonStateShape(state)).toBe(true); await expect( ensureTelegramDaemonRunningDetailed( @@ -3028,12 +3066,14 @@ describe("telegram daemon", () => { pidAlive: () => true, pidIncarnation: () => state.incarnation, sendSignal: (pid, signal) => signals.push([pid, signal]), + readinessTimeoutMs: 0, + sleep: async () => undefined, spawn: () => { throw new Error("future owner must not be replaced"); }, }, ), - ).resolves.toBe("attached"); + ).resolves.toBe("blocked_identity"); expect(signals).toEqual([]); expect(fs.readFileSync(paths.state, "utf8")).toBe(stateBefore); expect(fs.readFileSync(paths.lock, "utf8")).toBe(lockBefore); @@ -3073,7 +3113,7 @@ describe("telegram daemon", () => { }); expect(result).toEqual({ acquired: false, attached: false, reloadRequired: true }); }); - test("same servingEpoch attaches across an immediately preceding generation", async () => { + test("same servingEpoch cross-generation owner remains provisional for exact-generation handoff", async () => { const agentDir = tempAgentDir(); const s = setPrivateAgentDir(settings(agentDir), agentDir); writeLiveOwner(agentDir, { generation: DAEMON_GENERATION - 1, heartbeatAt: Date.now() }); @@ -3085,14 +3125,14 @@ describe("telegram daemon", () => { pidIncarnation: () => "linux:100", now: () => Date.now(), }); - expect(result).toEqual({ acquired: false, attached: true }); + expect(result).toEqual({ acquired: false, attached: false, provisional: true }); await expect( new TelegramDaemonController(s, { now: () => 101, pidAlive: pid => pid === 999, pidIncarnation: () => "linux:100", }).status(), - ).resolves.toMatchObject({ health: "running", pid: 999 }); + ).resolves.toMatchObject({ health: "stale", pid: 999 }); const signals: Array<[number, string]> = []; await expect( ensureTelegramDaemonRunningDetailed( @@ -3103,16 +3143,18 @@ describe("telegram daemon", () => { pidAlive: pid => pid === 999, pidIncarnation: () => "linux:100", sendSignal: (pid, signal) => signals.push([pid, signal]), + readinessTimeoutMs: 0, + sleep: async () => undefined, spawn: () => { - throw new Error("same-epoch predecessor must attach"); + throw new Error("cross-generation predecessor must not be replaced"); }, }, ), - ).resolves.toBe("attached"); + ).resolves.toBe("blocked_identity"); expect(signals).toEqual([]); }); - test("readiness attaches a same-epoch cross-generation owner but rejects a malformed generation", async () => { + test("readiness rejects same-epoch cross-generation and malformed-generation owners", async () => { const agentDir = tempAgentDir(); const s = setPrivateAgentDir(settings(agentDir), agentDir); const now = 1_000; @@ -3127,10 +3169,9 @@ describe("telegram daemon", () => { waitStepMs: 5, timeoutMs: 50, }; - // Serving-epoch gated, not generation gated: a same-epoch owner from the - // immediately preceding generation is a compatible ready owner. + // Readiness requires the exact generation-31, serving-epoch-4 authority. writeLiveOwner(agentDir, { generation: DAEMON_GENERATION - 1, servingEpoch: SERVING_EPOCH, heartbeatAt: now }); - await expect(waitForTelegramDaemonReady(readiness)).resolves.toBe(true); + await expect(waitForTelegramDaemonReady(readiness)).resolves.toBe(false); // A fractional generation has no lifecycle authority and never publishes readiness. writeLiveOwner(agentDir, { generation: DAEMON_GENERATION - 0.5, @@ -3522,23 +3563,23 @@ describe("telegram daemon", () => { pid: 999, incarnation: "linux:104", generation: DAEMON_GENERATION, + servingEpoch: SERVING_EPOCH, }); - await expect( - ensureTelegramDaemonRunningDetailed( - { settings: s, cwd: agentDir, sessionId: "reused-pid" }, - { - pid: 4242, - pidAlive: pid => pid === 999 || pid === 4243, - pidIncarnation: pid => (pid === 999 ? "linux:105" : "linux:100"), - sendSignal: (pid, signal) => signals.push([pid, signal]), - spawn: child.spawn, - sleep: child.sleep, - waitStepMs: 1, - readinessTimeoutMs: 10, - }, - ), - ).resolves.toBe("spawned"); + const result = await ensureTelegramDaemonRunningDetailed( + { settings: s, cwd: agentDir, sessionId: "reused-pid" }, + { + pid: 4242, + pidAlive: pid => pid === 999 || pid === 4243, + pidIncarnation: pid => (pid === 999 ? "linux:105" : "linux:100"), + sendSignal: (pid, signal) => signals.push([pid, signal]), + spawn: child.spawn, + sleep: child.sleep, + waitStepMs: 1, + readinessTimeoutMs: 100, + }, + ); + expect(result).toBe("spawned"); expect(signals).toEqual([]); expect(spawns).toBe(1); @@ -3602,7 +3643,7 @@ describe("telegram daemon", () => { expect(fs.readFileSync(paths.steal, "utf8")).toBe(transitionLockBefore); }); - test("#2028 acquire does not downgrade a NEWER-generation live owner (attaches)", async () => { + test("#2028 keeps a NEWER-generation live owner provisional for exact-generation handoff", async () => { const agentDir = tempAgentDir(); const s = setPrivateAgentDir(settings(agentDir), agentDir); writeLiveOwner(agentDir, { generation: DAEMON_GENERATION + 1 }); @@ -3614,7 +3655,7 @@ describe("telegram daemon", () => { pidIncarnation: () => "linux:100", now: () => 101, }); - expect(result).toEqual({ acquired: false, attached: true }); + expect(result).toEqual({ acquired: false, attached: false, provisional: true }); }); test("#2028 acquiring ownership stamps the current daemon generation into state", async () => { @@ -4276,7 +4317,7 @@ describe("telegram daemon", () => { pidAlive: pid => pid === 999, pidIncarnation: () => "linux:100", }), - ).toBe(true); + ).toBe(false); const ownership = await acquireDaemonOwnership({ settings: s, @@ -5565,6 +5606,7 @@ describe("telegram daemon", () => { } await runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => s }, + loadInstallationHostId: async () => "test-host", DaemonImpl: OneShotDaemon, processPid: 222, readDaemonState: async () => undefined, @@ -5849,6 +5891,7 @@ describe("telegram daemon", () => { } const run = runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => s }, + loadInstallationHostId: async () => "test-host", DaemonImpl: StubDaemon, readDaemonState: async () => ({ ownerId: "replacement", heartbeatAt: 1 }) as never, setInterval: callback => { @@ -5884,6 +5927,7 @@ describe("telegram daemon", () => { } const run = runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => s }, + loadInstallationHostId: async () => "test-host", DaemonImpl: StubDaemon, now: () => now, readDaemonState: async () => ({ ownerId: "owner", heartbeatAt: 1 }) as never, @@ -5929,6 +5973,7 @@ describe("telegram daemon", () => { await runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => s }, DaemonImpl: StubDaemon, + loadInstallationHostId: async () => "test-host", pidAlive: () => true, }); @@ -6479,9 +6524,62 @@ describe("telegram daemon", () => { expect(bot.calls.filter(call => call.method === "sendMessage")).toHaveLength(0); expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), ).toEqual([79]); - expect((daemon as any).topics.get("S")).toBeUndefined(); + expect((daemon as any).topics.get("S")).toMatchObject({ topicId: "79", authorityState: "inactive" }); + }); + test("a revoked accepted create retains its archive fence after two persistence failures", async () => { + FakeWs.instances = []; + const agentDir = tempAgentDir(); + const bot = new FakeBotApi(); + const createStarted = Promise.withResolvers(); + const releaseCreate = Promise.withResolvers(); + const originalCall = bot.call.bind(bot); + bot.call = async (method, body, options) => { + if (method === "createForumTopic") { + bot.calls.push({ method, body, options }); + createStarted.resolve(); + return releaseCreate.promise; + } + return originalCall(method, body, options); + }; + let failedWrites = 0; + let failArchiveFenceWrites = false; + const daemon = new TelegramNotificationDaemon({ + settings: setPrivateAgentDir(settings(agentDir), agentDir), + ownerId: "owner", + botToken: "tok", + chatId: "42", + botApi: bot, + rich: { enabled: false }, + WebSocketImpl: FakeWs as any, + fs: topicStateFs(async () => { + if (failArchiveFenceWrites && failedWrites++ < 2) throw new Error("injected archive fence write failure"); + }), + }); + daemon.connectSession("S", "ws://predecessor", "old"); + const predecessor = daemon.sessions.get("S")!; + const creating = (daemon as any).ensureTopic("S", "S", predecessor); + await createStarted.promise; + daemon.connectSession("S", "ws://successor", "new"); + failArchiveFenceWrites = true; + releaseCreate.resolve({ ok: true, result: { message_thread_id: 80 } }); + + await expect(creating).rejects.toThrow("injected archive fence write failure"); + expect(bot.calls.filter(call => call.method === "closeForumTopic")).toHaveLength(0); + expect((daemon as any).topics.get("S")).toMatchObject({ topicId: "80", authorityState: "archive_pending" }); + + await Bun.sleep(300); + const persisted = (await readTopicAuthorityState(agentDir)) as any; + expect(persisted.topics.S).toMatchObject({ topicId: "80", authorityState: "archive_pending" }); + expect(persisted.archiveJobs.S).toMatchObject({ topicId: "80", attempt: 1, backoffMs: 500 }); + expect(failedWrites).toBeGreaterThanOrEqual(3); + + await Bun.sleep(250); + await (daemon as any).reconcilePendingTopicDeletes(); + expect( + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), + ).toEqual([80]); }); test("a recovered model menu creates a bound topic and delivers its choices after public held creation", async () => { @@ -8071,7 +8169,7 @@ describe("telegram daemon", () => { question: "Proceed?", options: ["yes"], }); - (daemon as unknown as { topics: Map }).topics.delete("S"); + (daemon as any).topics.replace({ version: 2, topics: {} }); await daemon.handleSessionMessage(session, { type: "ask_selected_ack_request", mode: "live", @@ -8863,6 +8961,9 @@ describe("telegram daemon", () => { open: (file, flags, mode) => fs.promises.open(file, flags, mode), readdir: file => fs.promises.readdir(file), chmod: (file, mode) => fs.promises.chmod(file, mode), + lstat: (file, opts) => fs.promises.lstat(file, opts), + fsyncFile: async () => undefined, + fsyncDirectory: async () => undefined, }; const { bot, daemon, threadId } = await unavailableControlHarness(fsImpl); daemon.sessions.delete("S"); @@ -8912,7 +9013,7 @@ describe("telegram daemon", () => { const { agentDir, bot, daemon, threadId } = await unavailableControlHarness(); daemon.sessions.delete("S"); bot.calls = []; - Reflect.set(daemon, "pairedChatPrivate", undefined); + Reflect.set(daemon, "pairedChatPrivacy", undefined); const originalCall = bot.call.bind(bot); bot.call = async (method, body) => { if (method === "getChat") return { ok: true, result: { id: 42, type: "group" } }; @@ -10521,7 +10622,15 @@ test("stale identity after loadTopics reuses the persisted repo branch owner", a chatId: "42", botApi: bot, }); - const live = { sessionId: "LIVE", token: "tok", ws: { readyState: 1, send() {} }, pending: new Map() }; + const live = { + sessionId: "LIVE", + token: "tok", + ws: { readyState: 1, send() {} }, + pending: new Map(), + endpointKey: "canonical", + endpointDigest: endpointAuthorityDigest("ws://canonical", "canonical-token"), + hostGeneration: 1, + }; await firstDaemon.handleSessionMessage(live as any, { type: "identity_header", sessionId: "LIVE", @@ -10939,14 +11048,13 @@ test("a failed recovery write rolls back its binding before a concurrent rename test("resume recovery rejects unsafe durable topic bindings without creating or reusing a topic", async () => { const cases: Array<{ name: string; - mutate: (state: any) => void; + mutate: (state: TopicAuthorityState) => void; chatId?: string; url?: string; generation?: number; }> = [ { name: "cross chat", mutate: () => {}, chatId: "43" }, - { name: "delete pending", mutate: state => (state.topics.CANONICAL.authorityState = "delete_pending") }, - { name: "incomplete binding", mutate: state => delete state.topics.CANONICAL.endpointDigest }, + { name: "archive pending", mutate: state => (state.topics.CANONICAL.authorityState = "archive_pending") }, { name: "persisted malformed binding marker", mutate: state => (state.topics.CANONICAL.bindingMalformed = true) }, { name: "ambiguous topic", @@ -10982,7 +11090,6 @@ test("resume recovery rejects unsafe durable topic bindings without creating or ).toHaveLength(0); } }); - test("resume recovery preserves a user-owned durable topic name", async () => { FakeWs.instances = []; const agentDir = tempAgentDir(); @@ -11136,8 +11243,8 @@ test("malformed topic creation success is attempted once per endpoint and fails repo: "r", branch: "b", }), - ).rejects.toThrow("invalid message_thread_id"); - expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(2); + ).rejects.toThrow("topic create claim requires reconciliation"); + expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(1); }); test("cooldown-suppressed topic creation is a quiet per-attempt refusal", async () => { @@ -11155,12 +11262,17 @@ test("cooldown-suppressed topic creation is a quiet per-attempt refusal", async chatId: "42", botApi: bot, }); - Object.assign(daemon, { pairedChatPrivate: true }); + const cooldownUntil = Number.MAX_SAFE_INTEGER; + Object.assign(daemon, { + pairedChatPrivacy: "private", + botCooldownUntil: cooldownUntil, + warnedBotCooldownUntil: cooldownUntil, + }); const warning = spyOn(logger, "warn").mockImplementation(() => {}); try { await expect((daemon as any).ensureTopic("S", "topic")).resolves.toBeUndefined(); await expect((daemon as any).ensureTopic("S", "topic")).resolves.toBeUndefined(); - expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(2); + expect(bot.calls.filter(call => call.method === "createForumTopic")).toHaveLength(0); expect(warning).not.toHaveBeenCalled(); } finally { warning.mockRestore(); @@ -11223,60 +11335,10 @@ test("topic persistence failures fail closed without flat delivery", async () => }), ).rejects.toThrow("topic persistence failed"); expect(bot.calls.filter(call => call.method === "sendMessage")).toHaveLength(0); - expect(bot.calls.filter(call => call.method === "deleteForumTopic")).toHaveLength(1); + expect(bot.calls.filter(call => call.method === "closeForumTopic")).toHaveLength(0); expect((daemon as any).topics.get("S")?.authorityState).not.toBe("active"); }); -test("first-create compensation persists a failed durable clear for restart replay", async () => { - FakeWs.instances = []; - const agentDir = tempAgentDir(); - const bot = new FakeBotApi(); - let topicWrites = 0; - const daemon = new TelegramNotificationDaemon({ - settings: settings(agentDir), - ownerId: "owner", - botToken: "tok", - chatId: "42", - botApi: bot, - fs: topicStateFs(async () => { - topicWrites++; - if (topicWrites === 1) throw new Error("initial topic persistence failed"); - if (topicWrites === 3) throw new Error("durable compensation clear failed"); - }), - }); - const session = { sessionId: "S", token: "tok", ws: { readyState: 1, send() {} }, pending: new Map() }; - - await expect( - daemon.handleSessionMessage(session as never, { - type: "identity_header", - sessionId: "S", - repo: "r", - branch: "b", - }), - ).rejects.toThrow("initial topic persistence failed"); - - const topicId = bot.createdTopicThreadIds[0]!; - expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), - ).toEqual([topicId]); - const retained = await readTopicAuthorityState(agentDir); - expect(retained.topics.S).toMatchObject({ - topicId: String(topicId), - authorityState: "delete_pending", - }); - expect((retained as { fences?: Record }).fences?.S).toBeGreaterThan(0); - - const restarted = recoveryDaemon(agentDir, bot); - await restarted.loadTopics(); - bot.calls.length = 0; - await restarted.scanRoots(); - - expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), - ).toEqual([topicId]); - expect((await readTopicAuthorityState(agentDir)).topics.S).toBeUndefined(); -}); - test("threaded mode off: multiple sessions share a single fallback notice", async () => { const agentDir = tempAgentDir(); const bot = new FakeBotApi(); @@ -11520,19 +11582,20 @@ test("activity busy frame sends a typing chat action into the session topic", as expect(bot.calls.some(c => c.method === "sendChatAction")).toBe(false); }); -test("session_closed deletes the topic and resume creates a fresh visible topic", async () => { +test("session_closed archives the topic and resume remains fenced", async () => { + FakeWs.instances = []; const agentDir = tempAgentDir(); const bot = new FakeBotApi(); - let now = 0; const daemon = new TelegramNotificationDaemon({ settings: settings(agentDir), ownerId: "owner", botToken: "tok", chatId: "42", botApi: bot, - now: () => now, + WebSocketImpl: FakeWs as any, }); - const session = { sessionId: "S", token: "tok", ws: { readyState: 1, send() {} }, pending: new Map() }; + daemon.connectSession("S", "ws://s", "tok"); + const session = daemon.sessions.get("S")!; await daemon.handleSessionMessage(session as any, { type: "identity_header", @@ -11552,11 +11615,10 @@ test("session_closed deletes the topic and resume creates a fresh visible topic" bot.calls = []; await daemon.handleSessionMessage(session as any, { type: "session_closed", sessionId: "S" }); - const deleted = bot.calls.find(c => c.method === "deleteForumTopic"); - expect(deleted).toBeTruthy(); - expect(deleted!.body.message_thread_id).toBe(threadId); + const archived = bot.calls.find(c => c.method === "closeForumTopic"); + expect(archived).toBeTruthy(); + expect(archived!.body.message_thread_id).toBe(threadId); - now = 10_000; bot.calls = []; await daemon.handleSessionMessage(session as any, { type: "identity_header", @@ -11565,18 +11627,13 @@ test("session_closed deletes the topic and resume creates a fresh visible topic" branch: "b", title: "resumed", }); - const create = bot.calls.find(c => c.method === "createForumTopic"); - const send = bot.calls.find(c => c.method === "sendMessage"); - expect(create).toBeTruthy(); - expect(create!.body.name).toBe("r/b - resumed"); - expect(send).toBeTruthy(); - expect(send!.body.message_thread_id).toBeTruthy(); - expect(bot.calls.some(c => c.method === "reopenForumTopic")).toBe(false); - expect(bot.calls.some(c => c.method === "sendMessage" && String(c.body.text).includes("queued-before-delete"))).toBe( - false, - ); + expect(bot.calls.some(c => c.method === "createForumTopic")).toBe(false); + expect(bot.calls.some(c => c.method === "sendMessage")).toBe(false); + expect( + bot.calls.some(c => c.method === "sendMessage" && String(c.body.text).includes("queued-before-archive")), + ).toBe(false); }); -test("delete-pending topics fence model choices and threaded frames while active topics still deliver", async () => { +test("archive-pending topics fence model choices and threaded frames while active topics still deliver", async () => { FakeWs.instances = []; const agentDir = tempAgentDir(); const bot = new FakeBotApi(); @@ -11619,14 +11676,14 @@ test("delete-pending topics fence model choices and threaded frames while active const call = bot.call.bind(bot); bot.call = async (method, body, options) => { - if (method === "deleteForumTopic") { + if (method === "closeForumTopic") { bot.calls.push({ method, body, options }); - return { ok: false, description: "delete outcome unknown" }; + return { ok: false, description: "archive outcome unknown" }; } return call(method, body, options); }; await daemon.handleSessionMessage(activeSession, { type: "session_closed", sessionId: "S" }); - expect(bot.calls.find(call => call.method === "deleteForumTopic")!.body.message_thread_id).toBe(topicId); + expect(bot.calls.find(call => call.method === "closeForumTopic")!.body.message_thread_id).toBe(topicId); bot.calls = []; daemon.connectSession("S", "ws://resumed", "replacement-token"); @@ -11642,7 +11699,7 @@ test("delete-pending topics fence model choices and threaded frames while active type: "turn_stream", sessionId: "S", phase: "finalized", - text: "must not target the delete-pending topic", + text: "must not target the archive-pending topic", }); expect(bot.calls.some(call => call.method === "createForumTopic")).toBe(false); @@ -11672,7 +11729,7 @@ test("queued selected acknowledgement is rejected after its topic lease is fence const topicId = bot.createdTopicThreadIds.at(-1)!; const internals = daemon as unknown as { flushPool(): Promise; - topics: { beginDelete(sessionId: string): unknown }; + topics: { beginArchive(sessionId: string): unknown }; }; const flushPool = internals.flushPool.bind(daemon); internals.flushPool = async () => {}; @@ -11685,7 +11742,7 @@ test("queued selected acknowledgement is rejected after its topic lease is fence actionId: "ask", deadlineAt: Date.now() + 8_000, }); - internals.topics.beginDelete("S"); + internals.topics.beginArchive("S"); internals.flushPool = flushPool; await flushPool(); @@ -11716,7 +11773,7 @@ test("held threaded frame is rejected after its topic lease is fenced", async () const topicId = bot.createdTopicThreadIds.at(-1)!; const internals = daemon as unknown as { flushPool(): Promise; - topics: { beginDelete(sessionId: string): unknown }; + topics: { beginArchive(sessionId: string): unknown }; }; const flushPool = internals.flushPool.bind(daemon); internals.flushPool = async () => {}; @@ -11727,7 +11784,7 @@ test("held threaded frame is rejected after its topic lease is fenced", async () phase: "finalized", text: "held output", }); - internals.topics.beginDelete("S"); + internals.topics.beginArchive("S"); internals.flushPool = flushPool; await flushPool(); @@ -11772,7 +11829,7 @@ test("held rich fallback cannot send to a fenced topic", async () => { text: "# held rich fallback", }); await richStarted.promise; - (daemon as unknown as { topics: { beginDelete(sessionId: string): unknown } }).topics.beginDelete("S"); + (daemon as unknown as { topics: { beginArchive(sessionId: string): unknown } }).topics.beginArchive("S"); releaseRich.resolve(); await delivery; expect(bot.calls.some(call => call.method === "sendMessage" && call.body.message_thread_id === topicId)).toBe(false); @@ -11824,7 +11881,7 @@ test("held edit fallback cannot send to a fenced topic", async () => { messageRef: "held-edit", }); await editStarted.promise; - (daemon as unknown as { topics: { beginDelete(sessionId: string): unknown } }).topics.beginDelete("S"); + (daemon as unknown as { topics: { beginArchive(sessionId: string): unknown } }).topics.beginArchive("S"); releaseEdit.resolve(); await delivery; expect(bot.calls.some(call => call.method === "sendMessage" && call.body.message_thread_id === topicId)).toBe(false); @@ -11868,7 +11925,7 @@ test("held draft delivery cannot continue to a fenced topic", async () => { text: "held draft", }); await draftStarted.promise; - (daemon as unknown as { topics: { beginDelete(sessionId: string): unknown } }).topics.beginDelete("S"); + (daemon as unknown as { topics: { beginArchive(sessionId: string): unknown } }).topics.beginArchive("S"); releaseDraft.resolve(); await delivery; expect(bot.calls.some(call => call.method === "sendMessage" && call.body.message_thread_id === topicId)).toBe(false); @@ -11888,7 +11945,7 @@ test("held topic-name reconciliation cannot edit a fenced topic", async () => { holdTopicWrite = true; const update = daemon.handleTelegramUpdate(forumTopicEditedUpdate(1, threadId, "held name")); await writeStarted.promise; - (daemon as unknown as { topics: { beginDelete(sessionId: string): unknown } }).topics.beginDelete("S"); + (daemon as unknown as { topics: { beginArchive(sessionId: string): unknown } }).topics.beginArchive("S"); releaseWrite.resolve(); await update; expect(bot.calls.some(call => call.method === "editForumTopic" && call.body.message_thread_id === threadId)).toBe( @@ -11914,10 +11971,10 @@ test("delete-pending identity owners are not selected for forwarding", async () branch: "b", }); const internals = daemon as unknown as { - topics: { beginDelete(sessionId: string): unknown }; + topics: { beginArchive(sessionId: string): unknown }; topicOwnerForIdentity(msg: { repo: string; branch: string }): string | undefined; }; - internals.topics.beginDelete("S"); + internals.topics.beginArchive("S"); expect(internals.topicOwnerForIdentity({ repo: "r", branch: "b" })).toBeUndefined(); }); @@ -12086,14 +12143,14 @@ test("session_closed revokes persisted ask aliases and pending replies before se FakeWs.instances = []; const agentDir = tempAgentDir(); const bot = new FakeBotApi(); - const deleteStarted = Promise.withResolvers(); - const releaseDelete = Promise.withResolvers(); + const archiveStarted = Promise.withResolvers(); + const releaseArchive = Promise.withResolvers(); const call = bot.call.bind(bot); bot.call = async (method, body, options) => { - if (method === "deleteForumTopic") { + if (method === "closeForumTopic") { bot.calls.push({ method, body, options }); - deleteStarted.resolve(); - await releaseDelete.promise; + archiveStarted.resolve(); + await releaseArchive.promise; return { ok: true, result: true }; } return call(method, body, options); @@ -12119,7 +12176,7 @@ test("session_closed revokes persisted ask aliases and pending replies before se const sent = bot.calls.find(call => call.method === "sendMessage" && call.body.reply_markup)!; const alias = sent.body.reply_markup.inline_keyboard[0][0].callback_data; const close = daemon.handleSessionMessage(session, { type: "session_closed", sessionId: "LOGICAL" }); - await deleteStarted.promise; + await archiveStarted.promise; const aliases = JSON.parse(fs.readFileSync(daemonPaths(agentDir).aliases, "utf8")); expect(Object.values(aliases.routes).some((route: any) => route.sessionId === "LOGICAL")).toBe(false); expect(Object.values(aliases.revokedRoutes).some((route: any) => route.sessionId === "LOGICAL")).toBe(false); @@ -12132,58 +12189,10 @@ test("session_closed revokes persisted ask aliases and pending replies before se callback_query: { id: "closed-ask", data: alias, message: { chat: { id: 42 } } }, }); expect((restarted as any).aliasTable.get(alias)).toBeUndefined(); - releaseDelete.resolve(); + releaseArchive.resolve(); await close; }); -test("a concurrent delete re-fence keeps a definite remote delete under durable supervision", async () => { - FakeWs.instances = []; - const agentDir = tempAgentDir(); - const bot = new FakeBotApi(); - const deleteStarted = Promise.withResolvers(); - const releaseDelete = Promise.withResolvers(); - const call = bot.call.bind(bot); - bot.call = async (method, body, options) => { - if (method === "deleteForumTopic") { - bot.calls.push({ method, body, options }); - deleteStarted.resolve(); - await releaseDelete.promise; - return { ok: true, result: true }; - } - return call(method, body, options); - }; - const daemon = new TelegramNotificationDaemon({ - settings: settings(agentDir), - ownerId: "owner", - botToken: "tok", - chatId: "42", - botApi: bot, - WebSocketImpl: FakeWs as any, - rich: { enabled: false }, - }); - daemon.connectSession("S", "ws://s", "token"); - await daemon.handleSessionMessage(daemon.sessions.get("S")!, { - type: "action_needed", - kind: "ask", - id: "ask", - question: "Continue?", - options: ["yes"], - }); - const topicId = String((daemon as any).topics.get("S").topicId); - - const deleting = (daemon as any).deleteTopic("S"); - await deleteStarted.promise; - (daemon as any).topics.beginDelete("S"); - releaseDelete.resolve(); - - await expect(deleting).resolves.toBe("post_dispatch_pending"); - expect((daemon as any).topics.get("S")).toMatchObject({ - topicId, - authorityState: "delete_pending", - }); - const persisted = JSON.parse(fs.readFileSync(path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), "utf8")); - expect(persisted.topics.S).toMatchObject({ topicId, authorityState: "delete_pending" }); -}); -test("closing endpoint stays fenced after delete settlement until final persistence and teardown", async () => { +test("closing endpoint stays fenced after archive settlement until final persistence and teardown", async () => { FakeWs.instances = []; const agentDir = tempAgentDir(); const finalWriteStarted = Promise.withResolvers(); @@ -12197,7 +12206,7 @@ test("closing endpoint stays fenced after delete settlement until final persiste const bot = new FakeBotApi(); const call = bot.call.bind(bot); bot.call = async (method, body, options) => { - if (method === "deleteForumTopic") holdFinalWrite = true; + if (method === "closeForumTopic") holdFinalWrite = true; return call(method, body, options); }; const daemon = new TelegramNotificationDaemon({ @@ -12309,7 +12318,7 @@ test("session_closed tombstones its endpoint generation so scans do not recreate bot.calls = []; await daemon.handleSessionMessage(daemon.sessions.get("S")!, { type: "session_closed", sessionId: "S" }); - expect(bot.calls.some(c => c.method === "deleteForumTopic")).toBe(true); + expect(bot.calls.some(c => c.method === "closeForumTopic")).toBe(true); expect(daemon.sessions.has("S")).toBe(false); bot.calls = []; @@ -13072,13 +13081,16 @@ test("callback reservation and accepted receipt wait for filesystem durability b const secondBarrier = Promise.withResolvers(); const releaseSecondBarrier = Promise.withResolvers(); let directoryBarriers = 0; + let aliasFileSynced = false; const fsImpl: TelegramDaemonFs = { ...(fs.promises as unknown as TelegramDaemonFs), open: async (file, flags, mode) => { const handle = await fs.promises.open(file, flags, mode); return { sync: async () => { - if (file === paths.dir) { + if (file.startsWith(paths.aliases)) aliasFileSynced = true; + if (file === paths.dir && aliasFileSynced) { + aliasFileSynced = false; directoryBarriers++; if (directoryBarriers === 2) { firstBarrier.resolve(); @@ -13093,6 +13105,22 @@ test("callback reservation and accepted receipt wait for filesystem durability b close: () => handle.close(), }; }, + fsyncFile: async file => { + const handle = await fs.promises.open(file, "r+"); + try { + await handle.sync(); + } finally { + await handle.close(); + } + }, + fsyncDirectory: async directory => { + const handle = await fsImpl.open(directory, "r"); + try { + await handle.sync?.(); + } finally { + await handle.close(); + } + }, }; const bot = new FakeBotApi(); const daemon = new TelegramNotificationDaemon({ @@ -14176,9 +14204,11 @@ test("scanRoots reaps stale and dead-PID session topics after the orphan grace w fs.writeFileSync( path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), JSON.stringify({ + version: 2, topics: { stale: { topicId: "101", + topicOrigin: "daemon_created", identitySent: true, createdAt: 0, name: "stale", @@ -14189,6 +14219,7 @@ test("scanRoots reaps stale and dead-PID session topics after the orphan grace w }, dead: { topicId: "102", + topicOrigin: "daemon_created", identitySent: true, createdAt: 0, name: "dead", @@ -14203,7 +14234,7 @@ test("scanRoots reaps stale and dead-PID session topics after the orphan grace w const bot = new FakeBotApi(); const originalBotCall = bot.call.bind(bot); bot.call = async (method: string, body: unknown): Promise => { - if (method === "deleteForumTopic" && (body as { message_thread_id?: unknown }).message_thread_id === 101) { + if (method === "closeForumTopic" && (body as { message_thread_id?: unknown }).message_thread_id === 101) { bot.calls.push({ method, body }); return { ok: false, description: "Bad Request: TOPIC_ID_INVALID" }; } @@ -14234,12 +14265,13 @@ test("scanRoots reaps stale and dead-PID session topics after the orphan grace w await daemon.scanRoots(); expect( bot.calls - .filter(c => c.method === "deleteForumTopic") + .filter(c => c.method === "closeForumTopic") .map(c => c.body.message_thread_id) .sort(), ).toEqual([101, 102]); persisted = JSON.parse(fs.readFileSync(path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), "utf8")); - expect(persisted.topics).toEqual({}); + expect(persisted.topics.stale).toMatchObject({ authorityState: "archive_pending" }); + expect(persisted.topics.dead).toMatchObject({ authorityState: "inactive" }); }); test("scanRoots reaps missing endpoint topics only when all roots are readable and grace has elapsed", async () => { @@ -14252,9 +14284,11 @@ test("scanRoots reaps missing endpoint topics only when all roots are readable a fs.writeFileSync( path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), JSON.stringify({ + version: 2, topics: { missing: { topicId: "201", + topicOrigin: "daemon_created", identitySent: true, createdAt: 0, name: "missing", @@ -14284,9 +14318,9 @@ test("scanRoots reaps missing endpoint topics only when all roots are readable a now += 60_000; await daemon.scanRoots(); - expect(bot.calls.filter(c => c.method === "deleteForumTopic").map(c => c.body.message_thread_id)).toEqual([201]); + expect(bot.calls.filter(c => c.method === "closeForumTopic").map(c => c.body.message_thread_id)).toEqual([201]); persisted = JSON.parse(fs.readFileSync(path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), "utf8")); - expect(persisted.topics).toEqual({}); + expect(persisted.topics.missing).toMatchObject({ authorityState: "inactive" }); const blockedAgentDir = tempAgentDir(); const blockedSettings = setPrivateAgentDir(settings(blockedAgentDir), blockedAgentDir); @@ -14299,9 +14333,11 @@ test("scanRoots reaps missing endpoint topics only when all roots are readable a fs.writeFileSync( path.join(daemonPaths(blockedAgentDir).dir, "telegram-topics.json"), JSON.stringify({ + version: 2, topics: { kept: { topicId: "202", + topicOrigin: "daemon_created", identitySent: true, createdAt: 0, name: "kept", @@ -14396,6 +14432,7 @@ test("runDaemonInternal wires SIGTERM to the daemon stop method", async () => { try { const runPromise = runDaemonInternal(["--agent-dir", agentDir, "--owner-id", "owner"], { SettingsImpl: { init: async () => s }, + loadInstallationHostId: async () => "test-host", DaemonImpl: StubDaemon as any, }); await new Promise(resolve => setTimeout(resolve, 5)); @@ -14409,6 +14446,195 @@ test("runDaemonInternal wires SIGTERM to the daemon stop method", async () => { } }); +test("validation mode rejects all inbound updates and does not persist the production topic registry", async () => { + const agentDir = tempAgentDir(); + const bot = new FakeBotApi(); + const call = bot.call.bind(bot); + bot.call = async (method, body, options) => { + if (method === "getChat") return { ok: true, result: { id: "-100123", type: "supergroup", is_forum: true } }; + return await call(method, body, options); + }; + const daemon = new TelegramNotificationDaemon({ + settings: settings(agentDir), + ownerId: "owner", + botToken: "tok", + chatId: "42", + validationTestSupergroupChatId: "-100123", + botApi: bot, + rich: { enabled: true }, + }); + daemon.connectSession("S", "ws://validation", "tok"); + await daemon.handleSessionMessage(daemon.sessions.get("S")!, { + type: "identity_header", + sessionId: "S", + repo: "r", + branch: "b", + }); + await daemon.handleSessionMessage(daemon.sessions.get("S")!, { + type: "turn_stream", + sessionId: "S", + phase: "finalized", + text: "ordinary validation delivery", + }); + await daemon.handleSessionMessage(daemon.sessions.get("S")!, { + type: "action_needed", + sessionId: "S", + kind: "ask", + id: "validation-ask", + question: "Validation action?", + options: ["Continue"], + }); + await daemon.handleSessionMessage(daemon.sessions.get("S")!, { + type: "action_resolved", + sessionId: "S", + id: "validation-ask", + }); + const topicEffects = bot.calls.filter( + call => + call.body && + typeof call.body === "object" && + "chat_id" in (call.body as Record) && + call.method !== "getChat", + ); + expect(topicEffects).not.toHaveLength(0); + for (const effect of topicEffects) { + expect((effect.body as { chat_id: unknown }).chat_id).toBe("-100123"); + if (effect.method !== "createForumTopic" && !("message_id" in (effect.body as Record))) + expect((effect.body as { message_thread_id?: unknown }).message_thread_id).toBeDefined(); + } + expect(fs.existsSync(path.join(daemonPaths(agentDir).dir, "telegram-callback-aliases.json"))).toBe(false); + expect(fs.existsSync(path.join(daemonPaths(agentDir).dir, "telegram-seen-updates.json"))).toBe(false); + expect(fs.existsSync(path.join(daemonPaths(agentDir).dir, "telegram-rich-sent-index.json"))).toBe(false); + expect(topicEffects.some(effect => effect.method === "sendRichMessage")).toBe(true); + expect(fs.existsSync(path.join(daemonPaths(agentDir).dir, "telegram-topics.json"))).toBe(false); + + bot.calls = []; + bot.updates = [ + { + update_id: 1, + message: { chat: { id: "-100123" }, from: { id: 1, is_bot: false }, text: "/session_close" }, + }, + { + update_id: 2, + callback_query: { + id: "callback", + from: { id: 1, is_bot: false }, + message: { chat: { id: "-100123" }, message_id: 1 }, + data: "answer:ignored", + }, + }, + ]; + await daemon.pollOnce(); + expect(bot.calls.map(call => call.method)).toEqual(["getUpdates"]); +}); +test("validation mode redirects unthreaded message edits and topic edits away from the production owner chat", async () => { + const agentDir = tempAgentDir(); + const bot = new FakeBotApi(); + const originalCall = bot.call.bind(bot); + bot.call = async (method, body, options) => { + if (method === "getChat") return { ok: true, result: { id: "-100123", type: "supergroup", is_forum: true } }; + return await originalCall(method, body, options); + }; + const daemon = new TelegramNotificationDaemon({ + settings: settings(agentDir), + ownerId: "owner", + botToken: "tok", + chatId: "42", + validationTestSupergroupChatId: "-100123", + botApi: bot, + toolActivity: { enabled: true }, + }); + const session = richSession(); + await daemon.handleSessionMessage(session, { type: "hello", capabilities: [LEGACY_TOOL_ACTIVITY_CAPABILITY] }); + await daemon.handleSessionMessage(session, { + type: "identity_header", + sessionId: "S", + repo: "repo", + branch: "branch", + }); + await daemon.handleSessionMessage(session, { + type: "identity_header", + sessionId: "S", + repo: "repo", + branch: "branch", + title: "Renamed", + }); + await daemon.handleSessionMessage(session, { + type: "tool_activity", + sessionId: "S", + toolCallId: "validation-edit", + toolName: "read", + phase: "started", + }); + await daemon.handleSessionMessage(session, { + type: "tool_activity", + sessionId: "S", + toolCallId: "validation-edit", + toolName: "read", + phase: "completed", + }); + + const forumEdits = bot.calls.filter(call => call.method === "editForumTopic"); + const messageEdits = bot.calls.filter(call => call.method === "editMessageText"); + expect(forumEdits).toHaveLength(1); + expect(messageEdits).toHaveLength(1); + expect(messageEdits[0]!.body).not.toHaveProperty("message_thread_id"); + for (const call of [...forumEdits, ...messageEdits]) expect(call.body).toMatchObject({ chat_id: "-100123" }); + expect( + bot.calls.some( + call => + (call.method === "editForumTopic" || call.method === "editMessageText") && + (call.body as { chat_id?: unknown }).chat_id === "42", + ), + ).toBe(false); +}); + +test("validation scanRoots leaves missing production roots and stale leak artifacts untouched", async () => { + const agentDir = tempAgentDir(); + const s = setPrivateAgentDir(settings(agentDir), agentDir); + const missingRoot = path.join(agentDir, "permanently-missing"); + await registerNotificationRoot({ settings: s, cwd: missingRoot, sessionId: "missing" }); + const paths = daemonPaths(agentDir); + const rootsBefore = fs.readFileSync(paths.roots); + fs.mkdirSync(paths.dir, { recursive: true }); + const artifact = path.join(paths.dir, ".gjc-exact-unlink-placeholder-stale"); + const artifactBytes = Buffer.from("retained validation artifact"); + fs.writeFileSync(artifact, artifactBytes); + fs.utimesSync(artifact, new Date(0), new Date(0)); + const writes: string[] = []; + const unlinks: string[] = []; + const baseFs = fs.promises as unknown as TelegramDaemonFs; + const spyFs: TelegramDaemonFs = { + ...baseFs, + writeFile: async (file, data, options) => { + writes.push(String(file)); + return await baseFs.writeFile(file, data, options); + }, + unlink: async file => { + unlinks.push(String(file)); + return await baseFs.unlink(file); + }, + }; + const bot = new FakeBotApi(); + const daemon = new TelegramNotificationDaemon({ + settings: s, + ownerId: "owner", + botToken: "tok", + chatId: "42", + validationTestSupergroupChatId: "-100123", + botApi: bot, + fs: spyFs, + now: () => 1_000_000, + }); + + await daemon.scanRoots(); + + expect(fs.readFileSync(paths.roots)).toEqual(rootsBefore); + expect(fs.readFileSync(artifact)).toEqual(artifactBytes); + expect(writes).toEqual([]); + expect(unlinks).toEqual([]); +}); + test("a long finalized turn is scheduled through the pool, not burst in one grant", async () => { const agentDir = tempAgentDir(); const bot = new FakeBotApi(); @@ -17843,14 +18069,21 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { photo: [{ file_id: "attachment" }], }, }); - expect(bot.calls.at(-1)).toMatchObject({ + expect( + bot.calls.find( + call => + call.method === "sendMessage" && + call.body.reply_parameters?.message_id === messageId && + call.body.text === "Usage: /btw ", + ), + ).toMatchObject({ method: "sendMessage", body: { message_thread_id: threadId, reply_parameters: { message_id: messageId }, text: "Usage: /btw ", }, - options: { noRetry: true, signal: expect.any(AbortSignal) }, + options: { signal: expect.any(AbortSignal) }, }); expect( FakeWs.instances @@ -18205,6 +18438,9 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { open: (file, flags, mode) => fs.promises.open(file, flags, mode), readdir: file => fs.promises.readdir(file), chmod: (file, mode) => fs.promises.chmod(file, mode), + lstat: (file, opts) => fs.promises.lstat(file, opts), + fsyncFile: async () => undefined, + fsyncDirectory: async () => undefined, }; const { bot, daemon, threadId } = await unavailableControlHarness(fsImpl); await enableEphemeralTurns(daemon); @@ -18466,9 +18702,9 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { call.body.text === "This /btw question stopped because the GJC session closed or changed. Reopen it and try again.", ); - const deleteIndex = bot.calls.findIndex(call => call.method === "deleteForumTopic"); + const archiveIndex = bot.calls.findIndex(call => call.method === "closeForumTopic"); expect(unavailableIndex).toBeGreaterThanOrEqual(0); - expect(deleteIndex).toBeGreaterThan(unavailableIndex); + expect(archiveIndex).toBeGreaterThan(unavailableIndex); }); test("retires a granted pool settlement so an item id can be safely reused", async () => { @@ -18483,10 +18719,10 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { await expect(second.settled).resolves.toBe("removed"); }); test.each([ - ["accepted", async () => ({ ok: true, result: true }), false], - ["rejected", async () => ({ ok: false, description: "TOPIC_ID_INVALID" }), false], - ["ambiguous", async () => Promise.reject(new Error("network lost")), true], - ])("close during an accepted create performs a compensating delete (%s)", async (_outcome, deleteResult, retained) => { + ["accepted", async () => ({ ok: true, result: true }), "inactive"], + ["rejected", async () => ({ ok: false, description: "TOPIC_ID_INVALID" }), "archive_pending"], + ["ambiguous", async () => Promise.reject(new Error("network lost")), "archive_pending"], + ])("close during an accepted create performs a compensating archive (%s)", async (_outcome, archiveResult, authorityState) => { const agentDir = tempAgentDir(); const bot = new FakeBotApi(); const createStarted = Promise.withResolvers(); @@ -18498,9 +18734,9 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { createStarted.resolve(); return createGate.promise; } - if (method === "deleteForumTopic") { + if (method === "closeForumTopic") { bot.calls.push({ method, body, options }); - return deleteResult(); + return archiveResult(); } return call(method, body, options); }; @@ -18513,18 +18749,17 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { }); const creating = (daemon as any).ensureTopic("S", "topic"); await createStarted.promise; - const closing = (daemon as any).deleteTopic("S"); + const closing = (daemon as any).archiveTopic("S"); createGate.resolve({ ok: true, result: { message_thread_id: 77 } }); await expect(creating).rejects.toThrow("topic authority was revoked during creation"); await closing; expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), ).toEqual([77]); const persisted = JSON.parse( fs.readFileSync(path.join(daemonPaths(agentDir).dir, "telegram-topics.json"), "utf8"), ); - if (retained) expect(persisted.topics.S).toMatchObject({ topicId: "77", authorityState: "delete_pending" }); - else expect(persisted.topics.S).toBeUndefined(); + expect(persisted.topics.S).toMatchObject({ topicId: "77", authorityState }); }); test("an accepted stale create persists its fence across restart before remote compensation", async () => { @@ -18533,8 +18768,8 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const bot = new FakeBotApi(); const createStarted = Promise.withResolvers(); const releaseCreate = Promise.withResolvers(); - const deleteStarted = Promise.withResolvers(); - const releaseDelete = Promise.withResolvers(); + const archiveStarted = Promise.withResolvers(); + const releaseArchive = Promise.withResolvers(); const originalCall = bot.call.bind(bot); bot.call = async (method, body, options) => { if (method === "createForumTopic") { @@ -18542,10 +18777,10 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { createStarted.resolve(); return releaseCreate.promise; } - if (method === "deleteForumTopic") { + if (method === "closeForumTopic") { bot.calls.push({ method, body, options }); - deleteStarted.resolve(); - return releaseDelete.promise; + archiveStarted.resolve(); + return releaseArchive.promise; } return originalCall(method, body, options); }; @@ -18557,12 +18792,12 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { await createStarted.promise; daemon.connectSession("CANONICAL", "ws://successor", "new"); releaseCreate.resolve({ ok: true, result: { message_thread_id: 78 } }); - await deleteStarted.promise; + await archiveStarted.promise; const fencedBeforeCompensation = await readTopicAuthorityState(agentDir); expect(fencedBeforeCompensation.topics.CANONICAL).toMatchObject({ topicId: "78", - authorityState: "delete_pending", + authorityState: "archive_pending", }); expect((fencedBeforeCompensation as { fences?: Record }).fences?.CANONICAL).toBeGreaterThan(0); const restarted = recoveryDaemon(agentDir, bot); @@ -18574,10 +18809,10 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { bot.calls.filter(call => call.method === "createForumTopic" || call.method === "sendMessage"), ).toHaveLength(0); expect((await readTopicAuthorityState(agentDir)).topics.CANONICAL).toMatchObject({ - authorityState: "delete_pending", + authorityState: "archive_pending", }); - releaseDelete.resolve({ ok: true, result: true }); + releaseArchive.resolve({ ok: true, result: true }); await predecessorReplay; }); @@ -18587,7 +18822,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const bot = new FakeBotApi(); const createStarted = Promise.withResolvers(); const releaseCreate = Promise.withResolvers(); - const deleteStarted = Promise.withResolvers(); + const archiveStarted = Promise.withResolvers(); const originalCall = bot.call.bind(bot); let heldCreate = true; bot.call = async (method, body, options) => { @@ -18596,7 +18831,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { createStarted.resolve(); return releaseCreate.promise; } - if (method === "deleteForumTopic") deleteStarted.resolve(); + if (method === "closeForumTopic") archiveStarted.resolve(); return originalCall(method, body, options); }; const daemon = recoveryDaemon(agentDir, bot); @@ -18614,15 +18849,15 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { successor.ws.dispatchEvent(new Event("open")); releaseCreate.resolve({ ok: true, result: { message_thread_id: 77 } }); await predecessorReplay; - await deleteStarted.promise; - for (let i = 0; i < 20 && (daemon as any).topics.get("CANONICAL") !== undefined; i++) + await archiveStarted.promise; + for (let i = 0; i < 20 && (daemon as any).topics.get("CANONICAL")?.authorityState !== "inactive"; i++) await new Promise(resolve => setTimeout(resolve, 1)); expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), ).toEqual([77]); expect(bot.calls.filter(call => call.method === "sendMessage" && call.body.message_thread_id === 77)).toEqual([]); - expect((daemon as any).topics.get("CANONICAL")).toBeUndefined(); + expect((daemon as any).topics.get("CANONICAL")).toMatchObject({ topicId: "77", authorityState: "inactive" }); heldCreate = false; await daemon.handleSessionMessage(successor, { @@ -18645,26 +18880,50 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { expect(routed.every(call => call.body.message_thread_id !== 77)).toBe(true); }); test.each([ - ["accepted remote delete", async () => ({ ok: true, result: true }), false], - ["already absent remote topic", async () => ({ ok: false, description: "message thread not found" }), false], - ["ambiguous remote delete", async () => ({ ok: false, description: "transport unavailable" }), true], - ] as const)("startup scan reconciles a crash-persisted delete fence after %s", async (_outcome, deleteResult, retained) => { + ["accepted remote archive", async () => ({ ok: true, result: true }), "inactive", "42", "42", [77]], + [ + "already closed remote topic", + async () => ({ ok: false, error_code: 400, description: "Bad Request: TOPIC_NOT_FOUND" }), + "inactive", + "42", + "42", + [77], + ], + [ + "ambiguous remote archive", + async () => ({ ok: false, description: "transport unavailable" }), + "archive_pending", + "42", + "42", + [77], + ], + [ + "re-paired chat retains an old-chat archive fence", + async () => ({ ok: true, result: true }), + "archive_pending", + "43", + "42", + [], + ], + ] as const)("startup scan reconciles a crash-persisted archive fence after %s", async (_outcome, archiveResult, authorityState, recordChatId, pairedChatId, expectedArchivedTopics) => { const agentDir = tempAgentDir(); const topicsPath = path.join(daemonPaths(agentDir).dir, "telegram-topics.json"); fs.mkdirSync(path.dirname(topicsPath), { recursive: true }); fs.writeFileSync( topicsPath, JSON.stringify({ + version: 2, topics: { S: { topicId: "77", + topicOrigin: "daemon_created", identitySent: true, createdAt: 1, - chatId: "42", + chatId: recordChatId, endpointKey: "ws://s", endpointDigest: endpointAuthorityDigest("ws://s", "token"), endpointGeneration: 1, - authorityState: "delete_pending", + authorityState: "archive_pending", authorityEpoch: 2, }, }, @@ -18674,15 +18933,15 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const bot = new FakeBotApi(); const call = bot.call.bind(bot); bot.call = async (method, body, options) => { - if (method !== "deleteForumTopic") return call(method, body, options); + if (method !== "closeForumTopic") return call(method, body, options); bot.calls.push({ method, body, options }); - return deleteResult(); + return archiveResult(); }; const daemon = new TelegramNotificationDaemon({ settings: settings(agentDir), ownerId: "restarted-owner", botToken: "tok", - chatId: "42", + chatId: pairedChatId, botApi: bot, }); @@ -18690,11 +18949,10 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { await daemon.scanRoots(); expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), - ).toEqual([77]); + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), + ).toEqual([...expectedArchivedTopics]); const persisted = JSON.parse(fs.readFileSync(topicsPath, "utf8")); - if (retained) expect(persisted.topics.S).toMatchObject({ topicId: "77", authorityState: "delete_pending" }); - else expect(persisted.topics.S).toBeUndefined(); + expect(persisted.topics.S).toMatchObject({ topicId: "77", chatId: recordChatId, authorityState }); }); test("failed close publication restores only close authority while retaining a concurrent user rename across restart", async () => { @@ -18752,7 +19010,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { expect(await closeFailure).toMatchObject({ message: "first close publication fails" }); const persisted = await readTopicAuthorityState(agentDir); expect(persisted.topics.S).toMatchObject({ topicId: String(threadId), name: "Still mine", nameOwner: "user" }); - expect(persisted.topics.S.authorityState).not.toBe("delete_pending"); + expect(persisted.topics.S.authorityState).not.toBe("archive_pending"); expect(persisted.closedEndpoints?.S).toBeUndefined(); const restartedBot = new FakeBotApi(); const restarted = recoveryDaemon(agentDir, restartedBot); @@ -18848,8 +19106,11 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const persisted = await readTopicAuthorityState(agentDir); expect(persisted.topics.S).toMatchObject({ topicId: String(topicId) }); - expect(persisted.topics.S.authorityState).not.toBe("delete_pending"); - expect(persisted.closedEndpoints?.PREDECESSOR).toBeUndefined(); + expect(persisted.topics.S.authorityState).not.toBe("archive_pending"); + expect(persisted.closedEndpoints?.PREDECESSOR).toMatchObject({ + chatId: "42", + endpointGeneration: 1, + }); await replayResumedIdentity(daemon, "PREDECESSOR", "S", { url: "ws://replacement", token: "replacement-token", @@ -19057,7 +19318,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const releaseReservation = Promise.withResolvers(); const internals = daemon as unknown as { reserveSeenUpdateId(updateId: number): Promise; - topics: { beginDelete(sessionId: string): unknown }; + topics: { beginArchive(sessionId: string): unknown }; }; const reserve = internals.reserveSeenUpdateId.bind(daemon); internals.reserveSeenUpdateId = async id => { @@ -19073,7 +19334,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { message: { chat: { id: 42 }, message_thread_id: threadId, text, message_id: messageId }, }); await reservationStarted.promise; - internals.topics.beginDelete("S"); + internals.topics.beginArchive("S"); releaseReservation.resolve(); await handling; @@ -19110,7 +19371,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { }, }); await downloadStarted.promise; - (daemon as any).topics.beginDelete("S"); + (daemon as any).topics.beginArchive("S"); releaseDownload.resolve(); await handling; @@ -19489,7 +19750,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { }), ); await replayResumedIdentity(daemon, "FAILED", "S", { url: "ws://failed", token: "failed-token" }); - expect(daemon.sessions.has("FAILED")).toBe(false); + expect(daemon.sessions.has("FAILED")).toBe(failure === "createForumTopic"); await replayResumedIdentity(daemon, "RECOVERED", "S", { url: "ws://recovered", token: "recovered-token", @@ -19547,6 +19808,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { topics: { A: { topicId: "101", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: binding.chatId, @@ -20325,6 +20587,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { topics: { S: { topicId: "888", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, authorityEpoch: 2, @@ -20340,22 +20603,22 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { releaseCreate.resolve({ ok: true, result: { message_thread_id: 777 } }); await expect(creating).resolves.toBeUndefined(); expect((daemon as any).topics.get("S")).toMatchObject({ topicId: "888" }); - expect((daemon as any).topics.get("S")?.authorityState).not.toBe("delete_pending"); + expect((daemon as any).topics.get("S")?.authorityState).not.toBe("archive_pending"); expect(bot.calls.filter(call => call.method === "deleteForumTopic")).toEqual([]); }); - test("post-dispatch delete rejection retains the exact tombstone despite a successor and restart", async () => { + test("post-dispatch archive rejection retains the exact tombstone despite a successor and restart", async () => { FakeWs.instances = []; const agentDir = tempAgentDir(); const bot = new FakeBotApi(); - const deleteStarted = Promise.withResolvers(); - const releaseDelete = Promise.withResolvers(); + const archiveStarted = Promise.withResolvers(); + const releaseArchive = Promise.withResolvers(); const originalCall = bot.call.bind(bot); - let holdDelete = true; + let holdArchive = true; bot.call = async (method, body, options) => { - if (method === "deleteForumTopic" && holdDelete) { + if (method === "closeForumTopic" && holdArchive) { bot.calls.push({ method, body, options }); - deleteStarted.resolve(); - return releaseDelete.promise; + archiveStarted.resolve(); + return releaseArchive.promise; } return originalCall(method, body, options); }; @@ -20364,26 +20627,29 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { const predecessor = daemon.sessions.get("PREDECESSOR")!; const topicId = bot.createdTopicThreadIds.at(-1)!; const closing = daemon.handleSessionMessage(predecessor, { type: "session_closed", sessionId: "S" }); - await deleteStarted.promise; + await archiveStarted.promise; daemon.connectSession("SUCCESSOR", "ws://successor", "new"); - releaseDelete.resolve({ ok: false, description: "transport unavailable" }); + releaseArchive.resolve({ ok: false, description: "transport unavailable" }); await closing; const fenced = await readTopicAuthorityState(agentDir); - expect(fenced.topics.S).toMatchObject({ topicId: String(topicId), authorityState: "delete_pending" }); + expect(fenced.topics.S).toMatchObject({ topicId: String(topicId), authorityState: "archive_pending" }); expect(fenced.topics.S.authorityState).not.toBe("active"); - holdDelete = false; + holdArchive = false; const restarted = recoveryDaemon(agentDir, bot); await restarted.loadTopics(); await restarted.scanRoots(); - expect((await readTopicAuthorityState(agentDir)).topics.S).toBeUndefined(); + expect((await readTopicAuthorityState(agentDir)).topics.S).toMatchObject({ + topicId: String(topicId), + authorityState: "archive_pending", + }); }); - test("revoked create retains a delete fence after two failed publications and ambiguous deletion across restart", async () => { + test("revoked create retains an archive fence after two failed publications and ambiguous archival across restart", async () => { FakeWs.instances = []; const createStarted = Promise.withResolvers(); const releaseCreate = Promise.withResolvers(); - let topicWrites = 0; + let fenceWriteFailures = 0; const agentDir = tempAgentDir(); const bot = new FakeBotApi(); const call = bot.call.bind(bot); @@ -20393,7 +20659,7 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { createStarted.resolve(); return releaseCreate.promise; } - if (method === "deleteForumTopic") { + if (method === "closeForumTopic") { bot.calls.push({ method, body, options }); return { ok: false, description: "transport unavailable" }; } @@ -20403,9 +20669,10 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { agentDir, bot, "42", - topicStateFs(async () => { - topicWrites++; - if (topicWrites <= 2) throw new Error("durable fence unavailable"); + topicStateFs(async (_file, data) => { + const snapshot = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data)); + if (snapshot.topics?.S?.authorityState === "archive_pending" && fenceWriteFailures++ < 2) + throw new Error("durable fence unavailable"); }), ); daemon.connectSession("S", "ws://old", "old"); @@ -20415,15 +20682,16 @@ describe("telegram daemon /btw reservation and capability boundaries", () => { daemon.connectSession("S", "ws://successor", "new"); releaseCreate.resolve({ ok: true, result: { message_thread_id: 909 } }); await expect(creating).rejects.toThrow("durable fence unavailable"); - expect(bot.calls.filter(call => call.method === "deleteForumTopic")).toHaveLength(1); + expect(bot.calls.filter(call => call.method === "closeForumTopic")).toHaveLength(0); + await Bun.sleep(600); const fenced = await readTopicAuthorityState(agentDir); - expect(fenced.topics.S).toMatchObject({ topicId: "909", authorityState: "delete_pending" }); + expect(fenced.topics.S).toMatchObject({ topicId: "909", authorityState: "archive_pending" }); const restarted = recoveryDaemon(agentDir, bot); await restarted.loadTopics(); bot.calls.length = 0; await restarted.scanRoots(); expect( - bot.calls.filter(call => call.method === "deleteForumTopic").map(call => call.body.message_thread_id), + bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), ).toEqual([909]); }); }); @@ -20760,9 +21028,9 @@ test("Telegram Bot API 429 cooldown clamps malformed retry_after values and does // --------------------------------------------------------------------------- describe("PR #3186 blockers", () => { - test("serving epoch 4 replaces pre-policy (epochs 1 through 3) daemons via isCurrentCompatibleOwner", () => { + test("serving epoch 5 replaces pre-policy (epoch 1 through 4) daemons via isCurrentCompatibleOwner", () => { // Epoch 1: legacy daemon states that never published servingEpoch. - expect(SERVING_EPOCH).toBe(4); + expect(SERVING_EPOCH).toBe(5); const freshInput = (servingEpoch?: number) => { const state: DaemonState = { pid: 999, @@ -20790,14 +21058,13 @@ describe("PR #3186 blockers", () => { }; // Epoch undefined (epoch 1) — not compatible. expect(isCurrentCompatibleOwner(freshInput(undefined))).toBe(false); - // Epoch 2 — not compatible with epoch 4. - expect(isCurrentCompatibleOwner(freshInput(2))).toBe(false); - // Epoch 3 — not compatible with epoch 4. - expect(isCurrentCompatibleOwner(freshInput(3))).toBe(false); - // Epoch 4 — compatible. - expect(isCurrentCompatibleOwner(freshInput(4))).toBe(true); - // Future epoch 5 — still compatible (>= check). + + // Epoch 4 — not compatible with epoch 5. + expect(isCurrentCompatibleOwner(freshInput(4))).toBe(false); + // Epoch 5 — compatible. expect(isCurrentCompatibleOwner(freshInput(5))).toBe(true); + // Future epoch 6 — rejected fail-closed. + expect(isCurrentCompatibleOwner(freshInput(6))).toBe(false); }); test("visible v1 starts are terminated with terminalization on policy transition", async () => { @@ -21125,6 +21392,62 @@ describe("PR #3186 blockers", () => { expect(edits.length).toBeGreaterThanOrEqual(1); }); }); +test("explicit validation forum permits topic lifecycle but rejects mismatched and non-forum chats", async () => { + const createDaemon = ( + chat: { type: string; is_forum?: boolean }, + validationChatId = "-100123", + returnedChatId = "-100123", + ) => { + const agentDir = tempAgentDir(); + const bot = new FakeBotApi(); + const call = bot.call.bind(bot); + bot.call = async (method, body, options) => { + if (method === "getChat") return { ok: true, result: { ...chat, id: returnedChatId } }; + return await call(method, body, options); + }; + const daemon = new TelegramNotificationDaemon({ + settings: settings(agentDir), + ownerId: "owner", + botToken: "tok", + chatId: "42", + validationTestSupergroupChatId: validationChatId, + botApi: bot, + }); + daemon.connectSession("S", "ws://validation", "tok"); + return { bot, daemon, session: daemon.sessions.get("S")! }; + }; + + const allowed = createDaemon({ type: "supergroup", is_forum: true }); + await allowed.daemon.handleSessionMessage(allowed.session, { + type: "identity_header", + sessionId: "S", + repo: "r", + branch: "b", + }); + const topicId = allowed.bot.createdTopicThreadIds[0]; + expect(topicId).toBeDefined(); + await allowed.daemon.handleSessionMessage(allowed.session, { type: "session_closed", sessionId: "S" }); + expect( + allowed.bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id), + ).toEqual([topicId]); + expect(allowed.bot.calls.filter(call => call.method === "deleteForumTopic")).toEqual([]); + + for (const rejected of [ + createDaemon({ type: "supergroup", is_forum: false }), + createDaemon({ type: "supergroup", is_forum: true }, "-100999"), + createDaemon({ type: "group", is_forum: true }), + ]) { + await rejected.daemon.handleSessionMessage(rejected.session, { + type: "identity_header", + sessionId: "S", + repo: "r", + branch: "b", + }); + expect(rejected.bot.calls.some(call => call.method === "createForumTopic")).toBe(false); + expect(rejected.bot.calls.some(call => call.method === "sendMessage")).toBe(false); + } +}); + function forumTopicCreatedUpdate( updateId: number, threadId: number, @@ -21625,11 +21948,15 @@ describe("forum_topic_created user-topic adoption", () => { ); bot.calls = []; await daemon.handleSessionMessage(session, { type: "session_closed", sessionId: "adopter" }); - expect(bot.calls.filter(c => c.method === "deleteForumTopic")).toHaveLength(0); - expect(topicAccess(daemon).topics.get("adopter")).toBeUndefined(); + expect(bot.calls.filter(c => c.method === "deleteForumTopic" || c.method === "closeForumTopic")).toHaveLength(0); + expect(topicAccess(daemon).topics.get("adopter")).toMatchObject({ + topicId: "570", + topicOrigin: "user_created", + authorityState: "active", + }); }); - test("restart reconciliation settles adopted delete fences locally without deleting the user topic", async () => { + test("restart reconciliation settles adopted archive fences locally without closing the user topic", async () => { const { daemon, bot } = await adoptionLifecycleHarness(); (topicAccess(daemon).topics as unknown as { load(state: unknown): void }).load({ topics: { @@ -21649,8 +21976,12 @@ describe("forum_topic_created user-topic adoption", () => { fences: { adopter: 2 }, }); await (daemon as unknown as { reconcilePendingTopicDeletes(): Promise }).reconcilePendingTopicDeletes(); - expect(bot.calls.filter(c => c.method === "deleteForumTopic")).toHaveLength(0); - expect(topicAccess(daemon).topics.get("adopter")).toBeUndefined(); + expect(bot.calls.filter(c => c.method === "deleteForumTopic" || c.method === "closeForumTopic")).toHaveLength(0); + expect(topicAccess(daemon).topics.get("adopter")).toMatchObject({ + topicId: "571", + topicOrigin: "user_created", + authorityState: "inactive", + }); }); test("registry commit failure releases the adoption claim so the same topic can retry", async () => { @@ -21936,3 +22267,135 @@ describe("forum_topic_created user-topic adoption", () => { expect(bot.calls.some(c => c.method === "sendMessage" && c.body.reply_markup)).toBe(false); }); }); + +test("CAS retry exhaustion retains the latest strict shared-authority winner", async () => { + FakeWs.instances = []; + let readGeneration = 0; + let session: ReturnType; + const winner = (registryGeneration: number) => ({ + version: 2 as const, + registryGeneration, + topics: { + S: { + topicId: "700", + topicOrigin: "daemon_created" as const, + sessionUuid: "winner-session", + identitySent: true, + createdAt: 1, + authorityEpoch: 2, + authorityState: "disconnect_grace" as const, + orphanedAt: 10, + disconnectGraceExpiresAt: 500, + chatId: "42", + endpointKey: session.endpointKey, + endpointDigest: session.endpointDigest, + endpointGeneration: session.hostGeneration, + endpointIncarnation: 0, + ...(registryGeneration === 1 + ? {} + : { + leaseOwner: "winner-host", + leaseHeartbeatAt: 100, + leaseExpiresAt: 10_000, + }), + }, + }, + }); + const authority = { + read: async () => winner(++readGeneration), + compareAndSet: async () => false, + }; + const daemon = new TelegramNotificationDaemon({ + settings: settings(tempAgentDir()), + ownerId: "owner", + botToken: "token", + chatId: "42", + botApi: new FakeBotApi(), + WebSocketImpl: FakeWs as never, + now: () => 100, + installationHostId: "local-host", + topicRegistryAuthority: authority, + }); + session = daemon.connectSession("S", "ws://winner", "token"); + await daemon.loadTopics(); + session = daemon.connectSession("S", "ws://winner", "token"); + FakeWs.instances.at(-1)!.dispatchEvent(new Event("open")); + for (let attempt = 0; attempt < 100 && readGeneration < 4; attempt++) await Bun.sleep(10); + expect(readGeneration).toBe(4); + expect((daemon as unknown as { topics: { serialize(): unknown } }).topics.serialize()).toMatchObject({ + registryGeneration: 4, + topics: { + S: { + topicId: "700", + leaseOwner: "winner-host", + leaseExpiresAt: 10_000, + }, + }, + }); +}); +test("CAS winner advance after accepted create publishes the exact archive fence before one compensation", async () => { + FakeWs.instances = []; + const bot = new FakeBotApi(); + let state: TopicRegistryState = { version: 2, registryGeneration: 0, topics: {} }; + let endpointKey = ""; + let endpointDigest = ""; + let endpointGeneration = 0; + const authority = { + read: async () => state, + compareAndSet: async (expectedGeneration: number, next: TopicRegistryState) => { + if (expectedGeneration === 0) { + state = next; + return true; + } + if (expectedGeneration === 1) { + state = { version: 2, registryGeneration: 2, topics: {} }; + return false; + } + if (expectedGeneration === 2) { + expect(next.topics.S).toMatchObject({ + topicId: "2", + creationLeaseEpoch: 0, + authorityState: "archive_pending", + archiveHostId: "local-host", + archiveLeaseEpoch: next.topics.S?.authorityEpoch, + endpointKey, + endpointDigest, + endpointGeneration, + }); + state = next; + return true; + } + if (expectedGeneration === 3) { + state = next; + return true; + } + return false; + }, + }; + const daemon = new TelegramNotificationDaemon({ + settings: settings(tempAgentDir()), + ownerId: "owner", + botToken: "token", + chatId: "42", + botApi: bot, + WebSocketImpl: FakeWs as never, + installationHostId: "local-host", + topicRegistryAuthority: authority, + }); + const session = daemon.connectSession("S", "ws://session", "token"); + endpointKey = session.endpointKey; + endpointDigest = session.endpointDigest; + endpointGeneration = session.hostGeneration; + + await expect((daemon as any).ensureTopic("S", "topic", session)).rejects.toThrow("shared topic authority conflict"); + + expect(state.topics.S).toMatchObject({ + topicId: "2", + creationLeaseEpoch: 0, + authorityState: "inactive", + archiveHostId: "local-host", + }); + expect(bot.calls.filter(call => call.method === "closeForumTopic").map(call => call.body.message_thread_id)).toEqual( + [2], + ); +}); diff --git a/packages/coding-agent/test/notifications-topic-registry.test.ts b/packages/coding-agent/test/notifications-topic-registry.test.ts index 55ffb099b0..ba9aafa8a5 100644 --- a/packages/coding-agent/test/notifications-topic-registry.test.ts +++ b/packages/coding-agent/test/notifications-topic-registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { TopicRegistry, type TopicRegistryState } from "../src/sdk/bus/topic-registry"; +import { DAEMON_GENERATION, SERVING_EPOCH } from "../src/sdk/bus/telegram-daemon-contract"; +import { parseTopicRegistryState, TopicRegistry, type TopicRegistryState } from "../src/sdk/bus/topic-registry"; describe("TopicRegistry", () => { test("creates a topic once and reuses it on resume", async () => { @@ -93,6 +94,7 @@ describe("TopicRegistry", () => { topics: { bad: { topicId: "1", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -116,6 +118,7 @@ describe("TopicRegistry", () => { topics: { legacy: { topicId: "1", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -136,6 +139,7 @@ describe("TopicRegistry", () => { topics: { legacy: { topicId: "1", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -148,6 +152,7 @@ describe("TopicRegistry", () => { }, user: { topicId: "2", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -174,6 +179,7 @@ describe("TopicRegistry", () => { topics: { s1: { topicId: "42", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -190,7 +196,9 @@ describe("TopicRegistry", () => { }); test("retires an unbound legacy topic without validated chat affinity", async () => { - const reg = new TopicRegistry({ topics: { s1: { topicId: "42", identitySent: false, createdAt: 1 } } }); + const reg = new TopicRegistry({ + topics: { s1: { topicId: "42", topicOrigin: "daemon_created", identitySent: false, createdAt: 1 } }, + }); expect(reg.get("s1")).toBeUndefined(); expect(reg.sessionForTopic("42")).toBeUndefined(); expect( @@ -300,24 +308,70 @@ describe("TopicRegistry", () => { expect(results.map(r => r.topicId)).toEqual(["1", "1", "1"]); expect(reg.sessionForTopic("1")).toBe("s1"); }); + test("restored durable create claim blocks a second remote create", async () => { + const state: TopicRegistryState = { + version: 2, + topics: {}, + createClaims: { + s1: { sessionId: "s1", authorityEpoch: 0, createdAt: 1 }, + }, + }; + const reg = new TopicRegistry(state); + let creates = 0; + await expect( + reg.getOrCreateTopic("s1", async () => { + creates++; + return "2"; + }), + ).rejects.toThrow("topic create claim requires reconciliation"); + expect(creates).toBe(0); + expect(reg.pendingCreateClaims()).toEqual([{ sessionId: "s1", authorityEpoch: 0, createdAt: 1 }]); + }); + test("restored create claim rejects different active binding evidence", () => { + const claimBinding = { + chatId: "42", + endpointKey: "old-key", + endpointDigest: "old-digest", + endpointGeneration: 1, + }; + const reg = new TopicRegistry({ + version: 2, + topics: { + s1: { + topicId: "9", + topicOrigin: "daemon_created", + sessionUuid: "00000000-0000-4000-8000-000000000009", + identitySent: false, + createdAt: 1, + authorityEpoch: 0, + authorityState: "active", + chatId: "42", + endpointKey: "new-key", + endpointDigest: "new-digest", + endpointGeneration: 1, + endpointIncarnation: 0, + }, + }, + createClaims: { + s1: { sessionId: "s1", authorityEpoch: 0, createdAt: 1, binding: claimBinding }, + }, + }); + expect(reg.reconcileCreateClaim("s1", reg.get("s1"))).toBe(false); + expect(reg.pendingCreateClaims()).toEqual([ + { sessionId: "s1", authorityEpoch: 0, createdAt: 1, binding: claimBinding }, + ]); + }); - test("deletes topic records so later use creates a fresh topic", async () => { + test("retains archived topic records and never recreates physical topics", async () => { const reg = new TopicRegistry(); await reg.getOrCreateTopic("s1", async () => "1"); - expect(reg.delete("s1")).toBe(true); - expect(reg.delete("s1")).toBe(false); - expect(reg.get("s1")).toBeUndefined(); + reg.beginArchive("s1"); + expect(reg.get("s1")?.authorityState).toBe("archive_pending"); expect(reg.sessionForTopic("1")).toBeUndefined(); - let created = false; - const rec = await reg.getOrCreateTopic("s1", async () => { - created = true; - return "2"; - }); - expect(created).toBe(true); - expect(rec.topicId).toBe("2"); - expect(reg.sessionForTopic("2")).toBe("s1"); + await expect(reg.getOrCreateTopic("s1", async () => "2")).rejects.toThrow("topic authority is archive-fenced"); + expect(reg.get("s1")?.topicId).toBe("1"); }); test.each([ ["empty", ""], @@ -353,12 +407,12 @@ describe("TopicRegistry", () => { const reg = new TopicRegistry(); const created = Promise.withResolvers(); const create = reg.getOrCreateTopic("s1", () => created.promise); - expect(reg.beginDelete("s1")).toBeUndefined(); + expect(reg.beginArchive("s1")).toBeUndefined(); created.resolve("42"); await expect(create).rejects.toThrow("topic authority was revoked during creation"); - expect(reg.get("s1")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); + expect(reg.get("s1")).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.serialize().topics.s1).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); + expect(reg.serialize().topics.s1).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); }); test("never activates a staged topic whose authority is revoked during durable commit", async () => { const reg = new TopicRegistry(); @@ -370,13 +424,13 @@ describe("TopicRegistry", () => { undefined, undefined, async () => { - reg.beginDelete("s1"); + reg.beginArchive("s1"); }, ), ).rejects.toThrow("topic authority was revoked during creation"); expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.get("s1")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); - expect(reg.serialize().topics.s1).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); + expect(reg.get("s1")).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); + expect(reg.serialize().topics.s1).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); }); test("retains a delete-pending record and epoch without restoring its inbound route", async () => { const reg = new TopicRegistry(); @@ -386,14 +440,14 @@ describe("TopicRegistry", () => { endpointDigest: "digest-s1", endpointGeneration: 1, }); - reg.beginDelete("s1"); + reg.beginArchive("s1"); const reloaded = new TopicRegistry(reg.serialize()); - expect(reloaded.get("s1")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); + expect(reloaded.get("s1")).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); expect(reloaded.sessionForTopic("42")).toBeUndefined(); await expect(reloaded.getOrCreateTopic("s1", async () => "43")).rejects.toThrow( - "topic authority is deletion-fenced", + "topic authority is archive-fenced", ); }); test("fails closed after restart when a durable fence supersedes an active record epoch", async () => { @@ -409,7 +463,7 @@ describe("TopicRegistry", () => { const reloaded = new TopicRegistry(snapshot); - expect(reloaded.get("s1")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); + expect(reloaded.get("s1")).toMatchObject({ topicId: "42", authorityState: "archive_pending" }); expect(reloaded.sessionForTopic("42")).toBeUndefined(); }); test("rebuilds inbound routes from merged records on repeated load", async () => { @@ -426,6 +480,7 @@ describe("TopicRegistry", () => { topics: { s1: { topicId: "42", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -437,7 +492,7 @@ describe("TopicRegistry", () => { }, }); - expect(reg.get("s1")).toMatchObject({ authorityState: "delete_pending" }); + expect(reg.get("s1")).toMatchObject({ authorityState: "archive_pending" }); expect(reg.sessionForTopic("42")).toBeUndefined(); }); test.each([ @@ -450,6 +505,7 @@ describe("TopicRegistry", () => { topics: { [sessionId]: { topicId: "42", + topicOrigin: "daemon_created", identitySent: false, createdAt: 1, chatId: "42", @@ -462,8 +518,8 @@ describe("TopicRegistry", () => { }); } - expect(reg.get("active")?.authorityState).toBeUndefined(); - expect(reg.get("fenced")).toMatchObject({ authorityState: "delete_pending" }); + expect(reg.get("active")?.authorityState).toBe("active"); + expect(reg.get("fenced")).toMatchObject({ authorityState: "archive_pending" }); expect(reg.sessionForTopic("42")).toBeUndefined(); }); test("failed close restore retains a topic-id collision quarantine", async () => { @@ -475,11 +531,11 @@ describe("TopicRegistry", () => { endpointGeneration: 1, }); await reg.getOrCreateTopic("A", async () => "42", Date.now, undefined, binding("A")); - const snapshot = reg.captureDeleteAuthority("A"); - reg.beginDelete("A"); + const snapshot = reg.captureArchiveAuthority("A"); + reg.beginArchive("A"); await reg.getOrCreateTopic("B", async () => "42", Date.now, undefined, binding("B")); - expect(reg.restoreDeleteAuthority(snapshot)).toBe(true); + expect(reg.restoreArchiveAuthority(snapshot)).toBe(true); expect(reg.sessionForTopic("42")).toBeUndefined(); }); }); @@ -506,114 +562,259 @@ test("preserves a no-provenance endpoint claim before a held create can stage it await creating; expect(reg.endpointAuthority(binding)).toEqual({ state: "unique", sessionId: "B" }); }); -describe("isTopicIdAvailable (user-topic adoption)", () => { - test.each([ - ["empty", ""], - ["non-decimal", "1e2"], - ["zero", "0"], - ["negative", "-1"], - ["non-safe", "9007199254740992"], - ["non-string numeric", 123 as unknown as string], - ["null", null as unknown as string], - ])("rejects invalid topic ids without mutation (%s)", (_name, topicId) => { - const reg = new TopicRegistry(); - expect(reg.isTopicIdAvailable(topicId)).toBe(false); - expect(reg.serialize()).toEqual({ topics: {}, fences: {} }); +test("publishes generation 50 at serving epoch 5", () => { + expect(DAEMON_GENERATION).toBe(50); + expect(SERVING_EPOCH).toBe(5); +}); +test("archives pending topics into retained inactive records", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic("session", async () => "42", Date.now, undefined, { + chatId: "42", + endpointKey: "endpoint", + endpointDigest: "digest", + endpointGeneration: 1, + }); + + registry.beginArchive("session"); + expect(registry.get("session")?.authorityState).toBe("archive_pending"); + expect(registry.settleArchive("session", "42", registry.authorityEpoch("session"))).toBe(true); + expect(registry.get("session")?.authorityState).toBe("inactive"); + expect(registry.serialize().topics.session?.topicId).toBe("42"); +}); +test("a stale archive result cannot settle a newer archive fence", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic("session", async () => "43"); + expect(registry.beginArchive("session", "host-a", 100)).toBeDefined(); + const dispatchedEpoch = registry.authorityEpoch("session"); + expect(registry.beginArchive("session", "host-a", 101)).toBeDefined(); + + expect(registry.settleArchive("session", "43", dispatchedEpoch)).toBe(false); + expect(registry.get("session")).toMatchObject({ + topicId: "43", + authorityState: "archive_pending", + authorityEpoch: dispatchedEpoch + 1, }); +}); - test("reports an id available when no record, route, or stage claims it", () => { - const reg = new TopicRegistry(); - expect(reg.isTopicIdAvailable("42")).toBe(true); +test("saturated authority epochs fail closed for create, archive, and settlement", async () => { + const absent = new TopicRegistry({ + version: 2, + registryGeneration: 1, + topics: {}, + fences: { absent: Number.MAX_SAFE_INTEGER }, + }); + await expect(absent.getOrCreateTopic("absent", async () => "44")).rejects.toThrow( + "topic authority epoch is exhausted", + ); + + const saturated = new TopicRegistry({ + version: 2, + registryGeneration: 1, + topics: { + session: { + topicId: "45", + topicOrigin: "daemon_created", + sessionUuid: "session-uuid", + identitySent: false, + createdAt: 1, + authorityEpoch: Number.MAX_SAFE_INTEGER, + authorityState: "active", + chatId: "42", + endpointKey: "endpoint", + endpointDigest: "digest", + }, + }, + fences: { session: Number.MAX_SAFE_INTEGER }, }); + expect(saturated.beginArchive("session", "host-a", 100)).toBeUndefined(); + expect(saturated.get("session")?.authorityState).toBe("archive_exhausted"); + expect(saturated.settleArchive("session", "45", Number.MAX_SAFE_INTEGER)).toBe(false); + expect(saturated.archivePendingSessionIds(100)).toEqual(["session"]); +}); - test("rejects an id once an active topic is committed for it", async () => { - const reg = new TopicRegistry(); - await reg.getOrCreateTopic("s1", async () => "42"); - expect(reg.isTopicIdAvailable("42")).toBe(false); - expect(reg.isTopicIdAvailable("43")).toBe(true); - }); +test("rejects future topic registry versions and quarantines retained legacy records", () => { + expect(() => parseTopicRegistryState({ version: 3, topics: {} })).toThrow("unsupported future Telegram topic state"); - test("rejects an id held by a delete-pending (fenced) record", async () => { - const reg = new TopicRegistry(); - await reg.getOrCreateTopic("s1", async () => "42", Date.now, undefined, { - chatId: "42", - endpointKey: "ws://s1", - endpointDigest: "digest-s1", - endpointGeneration: 1, - }); - reg.beginDelete("s1"); - expect(reg.get("s1")?.authorityState).toBe("delete_pending"); - expect(reg.isTopicIdAvailable("42")).toBe(false); - }); + const state = parseTopicRegistryState({ + topics: { + legacy: { + topicId: "42", + topicOrigin: "daemon_created", + identitySent: true, + createdAt: 1, + chatId: "42", + endpointKey: "endpoint", + endpointDigest: "digest", + }, + }, + })!; + const registry = new TopicRegistry(state); - test("rejects an id that is ambiguous across two records", async () => { - const reg = new TopicRegistry(); - const binding = (sessionId: string) => ({ - chatId: "42", - endpointKey: `ws://${sessionId}`, - endpointDigest: `digest-${sessionId}`, - endpointGeneration: 1, - }); - await reg.getOrCreateTopic("A", async () => "42", Date.now, undefined, binding("A")); - await reg.getOrCreateTopic("B", async () => "42", Date.now, undefined, binding("B")); - expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); - }); + expect(registry.get("legacy")).toMatchObject({ topicId: "42", authorityState: "legacy_quarantined" }); + expect(registry.sessionForTopic("42")).toBeUndefined(); +}); +test("fences a concurrent host and permits same-topic resume only before grace expiry", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic( + "session", + async () => "42", + () => 100, + ); + expect(registry.acquireLease("session", "host-a", 100, 1_000, 500)).toBe(true); + expect(registry.acquireLease("session", "host-b", 200, 1_000, 500)).toBe(false); + expect(registry.releaseLeaseToGrace("session", "host-a", 300, 500)).toBe(true); + expect(registry.acquireLease("session", "host-a", 700, 1_000, 500)).toBe(true); + expect(registry.releaseLeaseToGrace("session", "host-a", 800, 500)).toBe(true); + expect(registry.acquireLease("session", "host-a", 1_301, 1_000, 500)).toBe(false); +}); - test("rejects an id held by a staged (not-yet-committed) create without mutating state", async () => { - const reg = new TopicRegistry(); - const commitGate = Promise.withResolvers(); - const creating = reg.getOrCreateTopic( - "s1", - async () => "42", - Date.now, - undefined, - undefined, - async () => { - // During the durable commit the record is staged-but-uncommitted; - // adoption must refuse the topic id to avoid a duplicate authority. - expect(reg.isTopicIdAvailable("42")).toBe(false); - await commitGate.promise; - }, - ); - commitGate.resolve(); - await creating; - expect(reg.isTopicIdAvailable("42")).toBe(false); - expect(reg.sessionForTopic("42")).toBe("s1"); +test("retains lease identity and registry generation across serialization", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic("session", async () => "42", Date.now, undefined, { + chatId: "42", + endpointKey: "endpoint", + endpointDigest: "digest", + }); + expect(registry.acquireLease("session", "host-a", 100, 1_000, 500)).toBe(true); + registry.markRegistryPublished(4); + const restored = new TopicRegistry(registry.serialize()); + expect(restored.registryVersion()).toBe(4); + expect(restored.get("session")).toMatchObject({ + sessionUuid: expect.any(String), + leaseOwner: "host-a", + leaseHeartbeatAt: 100, + leaseExpiresAt: 1_100, }); +}); +test("terminal archive states cannot be revived by lease or orphan transitions", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic( + "session", + async () => "42", + () => 0, + undefined, + { + chatId: "42", + endpointKey: "endpoint", + endpointDigest: "digest", + }, + ); + registry.beginArchive("session"); + for (let attempt = 0; attempt < 9; attempt++) registry.scheduleArchiveRetry("session", attempt); + expect(registry.get("session")?.authorityState).toBe("archive_pending"); + expect(registry.acquireLease("session", "host", 10, 1_000, 500)).toBe(false); + expect(registry.archivePendingSessionIds(70_000)).toEqual(["session"]); + expect(registry.archiveExhaustedSessionIds()).toEqual([]); + expect(registry.markOrphaned("session", 10)).toBe(false); + expect(registry.clearOrphaned("session")).toBe(false); + await expect(registry.getOrCreateTopic("session", async () => "43")).rejects.toThrow("archive-fenced"); +}); +test("durably publishes a pre-create claim before invoking the remote creator", async () => { + const registry = new TopicRegistry(); + const commit = Promise.withResolvers(); + let createCalled = false; + const creating = registry.getOrCreateTopic( + "session", + async () => { + createCalled = true; + return "42"; + }, + () => 100, + "topic", + { chatId: "42", endpointKey: "endpoint", endpointDigest: "digest" }, + () => commit.promise, + ); + await Promise.resolve(); + expect(createCalled).toBe(false); + expect(registry.serialize().createClaims?.session).toMatchObject({ + sessionId: "session", + authorityEpoch: 0, + createdAt: 100, + }); + commit.resolve(); + await creating; + expect(createCalled).toBe(true); + expect(registry.serialize().createClaims?.session).toBeUndefined(); +}); +test("retains adopted topics and rejects an unexpired foreign archive owner", async () => { + const registry = new TopicRegistry(); + await registry.getOrCreateTopic( + "session", + async () => "42", + () => 100, + undefined, + { chatId: "42", endpointKey: "endpoint", endpointDigest: "digest" }, + undefined, + undefined, + "user_created", + ); + expect(registry.beginArchive("session", "host-a", 100)).toBeUndefined(); + expect(registry.serialize().topics.session?.topicOrigin).toBe("user_created"); + + const daemonTopic = new TopicRegistry(); + await daemonTopic.getOrCreateTopic( + "daemon", + async () => "43", + () => 100, + undefined, + { chatId: "42", endpointKey: "endpoint-2", endpointDigest: "digest-2" }, + ); + expect(daemonTopic.acquireLease("daemon", "host-a", 100, 1_000, 0)).toBe(true); + expect(daemonTopic.beginArchive("daemon", "host-b", 101)).toBeUndefined(); + expect(daemonTopic.beginArchive("daemon", "host-b", 1_101)?.archiveHostId).toBe("host-b"); + expect(daemonTopic.archiveAuthorityAllows("daemon", "host-b", 1_101)).toBe(true); +}); +test("accepted-create compensation publishes exact host and archive epoch authority", async () => { + const registry = new TopicRegistry(); + const binding = { chatId: "42", endpointKey: "endpoint", endpointDigest: "digest", endpointGeneration: 1 }; + await registry.getOrCreateTopic( + "session", + async () => "44", + () => 100, + undefined, + binding, + ); + const fenced = registry.fenceAcceptedCreateForLease("session", "44", 0, "host-a", () => 101, undefined, binding); + expect(fenced).toMatchObject({ + topicId: "44", + authorityState: "archive_pending", + archiveHostId: "host-a", + archiveLeaseEpoch: 1, + authorityEpoch: 1, + }); + expect(registry.archiveAuthorityAllows("session", "host-a", 101)).toBe(true); + expect(registry.archiveAuthorityAllows("session", "host-b", 101)).toBe(false); +}); - test("adoption via getOrCreateTopic create callback commits exactly once with the user topic id", async () => { - const reg = new TopicRegistry(); - const binding = { +test("retains inactive predecessor evidence when an authenticated successor rotates", async () => { + const registry = new TopicRegistry(); + const original = { chatId: "42", endpointKey: "old", endpointDigest: "old-digest", endpointGeneration: 1 }; + await registry.getOrCreateTopic( + "session", + async () => "45", + () => 100, + undefined, + original, + ); + expect(registry.beginArchive("session", "host-a", 101)).toBeDefined(); + expect(registry.settleArchive("session", "45", registry.authorityEpoch("session"))).toBe(true); + expect( + registry.retireInactiveEndpointForSuccessor("session", { chatId: "42", - endpointKey: "ws://s1", - endpointDigest: "digest-s1", - endpointGeneration: 1, - }; - // The create callback returns a user-created topicId only after confirming - // pure availability; getOrCreateTopic then commits the full record once. - const record = await reg.getOrCreateTopic( - "s1", - async () => { - expect(reg.isTopicIdAvailable("77")).toBe(true); - return "77"; - }, - () => 1000, - "repo/main", - binding, - undefined, - undefined, - "user_created", - ); - expect(record.topicId).toBe("77"); - expect(record.creationLeaseEpoch).toBe(0); - expect(record.chatId).toBe("42"); - expect(reg.sessionForTopic("77")).toBe("s1"); - expect(reg.get("s1")?.endpointDigest).toBe("digest-s1"); - expect(reg.isTopicIdAvailable("77")).toBe(false); - expect(record.topicOrigin).toBe("user_created"); - const reloaded = new TopicRegistry(); - reloaded.load(reg.serialize()); - expect(reloaded.get("s1")?.topicOrigin).toBe("user_created"); - }); + endpointKey: "new", + endpointDigest: "new-digest", + endpointGeneration: 2, + }), + ).toBe(true); + const serialized = registry.serialize(); + expect(serialized.topics.session).toBeUndefined(); + expect(serialized.retiredTopics?.session).toEqual([ + expect.objectContaining({ + topicId: "45", + topicOrigin: "daemon_created", + authorityState: "inactive", + archiveHostId: "host-a", + }), + ]); + expect(new TopicRegistry(serialized).serialize().retiredTopics).toEqual(serialized.retiredTopics); }); diff --git a/packages/coding-agent/test/notifications-topic-settle-fence-epoch.test.ts b/packages/coding-agent/test/notifications-topic-settle-fence-epoch.test.ts index b800ae2eb0..781bab320f 100644 --- a/packages/coding-agent/test/notifications-topic-settle-fence-epoch.test.ts +++ b/packages/coding-agent/test/notifications-topic-settle-fence-epoch.test.ts @@ -1,263 +1,92 @@ import { describe, expect, test } from "bun:test"; -import { TopicRegistry, type TopicRegistryState, type TopicSettledDelete } from "../src/sdk/bus/topic-registry"; - -const binding = (sessionId: string) => ({ - chatId: "42", - endpointKey: `ws://${sessionId}`, - endpointDigest: `digest-${sessionId}`, - endpointGeneration: 1, -}); - -/** A persisted record with a complete endpoint binding (pre-binding records are retired on load). */ -const boundRecord = (sessionId: string, topicId: string, authorityEpoch: number, fenced: boolean) => ({ - topicId, - identitySent: false, - createdAt: 1, - authorityEpoch, - ...binding(sessionId), - ...(fenced ? { authorityState: "delete_pending" as const } : {}), -}); - -/** Narrow an accepted phase-1 settlement without weakening the refusal contract. */ -const requireSettled = (settled: TopicSettledDelete | undefined): TopicSettledDelete => { - if (!settled) throw new Error("expected the settlement to be accepted"); - return settled; -}; - -describe("TopicRegistry delete settlement fencing", () => { - test("a settled delete releases the topic-id quarantine so a re-adopted topic routes inbound", async () => { - const state: TopicRegistryState = { - topics: { A: boundRecord("A", "42", 1, true) }, - fences: { A: 1 }, - }; - const reg = new TopicRegistry(state); - - // The delete-pending record quarantines its topic id: not routable, not adoptable. - expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); - - const settled = requireSettled(reg.settleDelete("A", "42", reg.authorityEpoch("A"))); - expect(reg.commitSettledDelete(settled)).toBe(true); - - // Once the record is gone and its clear is durable, its topic id no longer - // collides, so it becomes adoptable and routable without a daemon restart. - expect(reg.get("A")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(true); - await reg.getOrCreateTopic( - "B", - async () => "42", - () => 2, - undefined, - binding("B"), - ); - expect(reg.sessionForTopic("42")).toBe("B"); +import { type TopicRecord, TopicRegistry, type TopicRegistryState } from "../src/sdk/bus/topic-registry"; + +function boundRecord( + topicId: string, + authorityEpoch: number, + authorityState: TopicRecord["authorityState"], +): TopicRecord { + return { + topicId, + topicOrigin: "daemon_created", + sessionUuid: `session-${topicId}`, + identitySent: false, + createdAt: 1, + authorityEpoch, + authorityState, + chatId: "chat", + endpointKey: "endpoint", + endpointDigest: "digest", + endpointIncarnation: 0, + }; +} + +function state(topics: Record, fences: Record): TopicRegistryState { + return { version: 2, registryGeneration: 1, topics, fences }; +} + +describe("TopicRegistry archive settlement fencing", () => { + test("a stale result cannot settle a newer archive fence", () => { + const registry = new TopicRegistry(state({ A: boundRecord("42", 1, "active") }, { A: 1 })); + expect(registry.beginArchive("A", "host", 1)?.authorityEpoch).toBe(2); + const staleEpoch = registry.authorityEpoch("A"); + expect(registry.beginArchive("A", "host", 2)?.authorityEpoch).toBe(3); + + expect(registry.settleArchive("A", "42", staleEpoch)).toBe(false); + expect(registry.get("A")).toMatchObject({ authorityEpoch: 3, authorityState: "archive_pending" }); + expect(registry.sessionForTopic("42")).toBeUndefined(); + expect(registry.isTopicIdAvailable("42")).toBe(false); }); - test("a stale E1 settlement cannot settle the newer E2 delete fence for the same session and topic", async () => { - const reg = new TopicRegistry(); - await reg.getOrCreateTopic( - "A", - async () => "42", - () => 1, - undefined, - binding("A"), - ); - - // E1 fences the session and dispatches its remote delete under this epoch. - reg.beginDelete("A"); - const dispatchedEpochE1 = reg.authorityEpoch("A"); - - // Before E1's definite result arrives, a scan/close-started E2 delete - // re-fences the same session and topic, superseding E1's authority. - reg.beginDelete("A"); - const dispatchedEpochE2 = reg.authorityEpoch("A"); - expect(dispatchedEpochE2).toBeGreaterThan(dispatchedEpochE1); - - // E1's definite result must not settle E2's fence. - expect(reg.settleDelete("A", "42", dispatchedEpochE1)).toBeUndefined(); - - // E2's delete_pending record and its quarantine survive intact. - expect(reg.get("A")).toMatchObject({ - topicId: "42", - authorityState: "delete_pending", - authorityEpoch: dispatchedEpochE2, - }); - expect(reg.authorityEpoch("A")).toBe(dispatchedEpochE2); - expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); - - // The owning E2 epoch still settles normally. - expect(reg.settleDelete("A", "42", dispatchedEpochE2)).toBeDefined(); - }); - - test("restoring the delete fence after a failed persist re-quarantines a colliding topic id", () => { - // Persisted active+pending collision: B is active on the same topic id that - // delete-pending A still holds, so the id is ambiguous and routes nowhere. - const state: TopicRegistryState = { - topics: { A: boundRecord("A", "42", 1, true), B: boundRecord("B", "42", 0, false) }, - fences: { A: 1 }, - }; - const reg = new TopicRegistry(state); - expect(reg.sessionForTopic("42")).toBeUndefined(); - - const snapshot = reg.captureDeleteAuthority("A"); - const settled = requireSettled(reg.settleDelete("A", "42", reg.authorityEpoch("A"))); - expect(reg.commitSettledDelete(settled)).toBe(true); - - // The committed clear rebuilt derived routes, so the surviving colliding - // record is now routable. - expect(reg.sessionForTopic("42")).toBe("B"); - - // A later close-path publication fails and the delete fence is reinstated. - expect(reg.restoreDeleteFence(snapshot)).toBe(true); + test("a definite result retains inactive authority and its topic-id quarantine", () => { + const registry = new TopicRegistry(state({ A: boundRecord("42", 1, "archive_pending") }, { A: 1 })); - // The restored fence must re-quarantine the topic id; inbound routing to the - // collision partner must not stay open. - expect(reg.get("A")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); - expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); + expect(registry.settleArchive("A", "42", 1)).toBe(true); + expect(registry.get("A")).toMatchObject({ topicId: "42", authorityEpoch: 1, authorityState: "inactive" }); + expect(registry.sessionForTopic("42")).toBeUndefined(); + expect(registry.isTopicIdAvailable("42")).toBe(false); }); - test("authority epochs saturate at the safe-integer bound and a saturated fence refuses settlement", () => { - const max = Number.MAX_SAFE_INTEGER; - const state: TopicRegistryState = { - topics: { A: boundRecord("A", "42", max, false) }, - fences: { A: max }, - }; - const reg = new TopicRegistry(state); - expect(reg.authorityEpoch("A")).toBe(max); - - // Fencing at the bound must not produce MAX_SAFE_INTEGER + 1: that value is - // not a safe integer and compares equal to its own successor, so it could - // never distinguish one delete generation from the next. - expect(reg.beginDelete("A")?.authorityEpoch).toBe(max); - expect(reg.authorityEpoch("A")).toBe(max); - expect(Number.isSafeInteger(reg.authorityEpoch("A"))).toBe(true); - expect(reg.serialize().fences?.A).toBe(max); - - // A saturated epoch can no longer prove exclusive authority, so settlement - // fails closed: the fence and the topic-id quarantine are both retained. - expect(reg.settleDelete("A", "42", max)).toBeUndefined(); - expect(reg.get("A")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); - expect(reg.isTopicIdAvailable("42")).toBe(false); + test("a failed publication restores only its exact archive fence", () => { + const registry = new TopicRegistry(state({ A: boundRecord("42", 1, "active") }, { A: 1 })); + const snapshot = registry.captureArchiveAuthority("A"); + expect(registry.beginArchive("A", "host", 1)?.authorityEpoch).toBe(2); + expect(registry.settleArchive("A", "42", 2)).toBe(true); - // Dispatched epochs that are not non-negative safe integers are rejected - // outright rather than compared numerically. - expect(reg.settleDelete("A", "42", max + 1)).toBeUndefined(); - expect(reg.settleDelete("A", "42", -1)).toBeUndefined(); - expect(reg.settleDelete("A", "42", Number.NaN)).toBeUndefined(); - expect(reg.settleDelete("A", "42", 1.5)).toBeUndefined(); + expect(registry.restoreArchiveFence(snapshot)).toBe(true); + expect(registry.get("A")).toMatchObject({ authorityEpoch: 2, authorityState: "archive_pending" }); + expect(registry.sessionForTopic("42")).toBeUndefined(); }); - test("a saturated stale restore cannot reactivate a newer delete fence", () => { - const max = Number.MAX_SAFE_INTEGER; - const reg = new TopicRegistry({ - topics: { A: boundRecord("A", "42", max, false) }, - fences: { A: max }, - }); - const snapshot = reg.captureDeleteAuthority("A"); - - reg.beginDelete("A"); - reg.beginDelete("A"); + test("a stale rollback cannot reactivate a newer archive generation", () => { + const registry = new TopicRegistry(state({ A: boundRecord("42", 1, "active") }, { A: 1 })); + const snapshot = registry.captureArchiveAuthority("A"); + expect(registry.beginArchive("A", "host", 1)?.authorityEpoch).toBe(2); + expect(registry.settleArchive("A", "42", 2)).toBe(true); + expect(registry.beginArchive("A", "host", 2)?.authorityEpoch).toBe(3); - expect(reg.restoreDeleteAuthority(snapshot)).toBe(false); - expect(reg.get("A")).toMatchObject({ - topicId: "42", - authorityEpoch: max, - authorityState: "delete_pending", - }); - expect(reg.sessionForTopic("42")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); + expect(registry.restoreArchiveFence(snapshot)).toBe(false); + expect(registry.get("A")).toMatchObject({ authorityEpoch: 3, authorityState: "archive_pending" }); }); - test("a saturated creation epoch refuses remote creation without invoking its callback", async () => { + test("saturated authority refuses create, archive, settlement, and rollback", async () => { const max = Number.MAX_SAFE_INTEGER; - const reg = new TopicRegistry({ topics: {}, fences: { A: max } }); + const registry = new TopicRegistry(state({ A: boundRecord("42", max, "active") }, { A: max })); + const snapshot = registry.captureArchiveAuthority("A"); + const absent = new TopicRegistry(state({}, { missing: max })); let createCalled = false; await expect( - reg.getOrCreateTopic("A", async () => { + absent.getOrCreateTopic("missing", async () => { createCalled = true; - return "42"; + return "43"; }), - ).rejects.toThrow("topic authority epoch exhausted"); - + ).rejects.toThrow("topic authority epoch is exhausted"); expect(createCalled).toBe(false); - expect(reg.get("A")).toBeUndefined(); - expect(reg.authorityEpoch("A")).toBe(max); - }); - - test("a rollback refuses any settlement whose post-settlement state no longer holds", async () => { - const reg = new TopicRegistry({ topics: { A: boundRecord("A", "42", 1, true) }, fences: { A: 1 } }); - const settled = requireSettled(reg.settleDelete("A", "42", reg.authorityEpoch("A"))); - - // A concurrent re-fence advances the session epoch past the settlement, so - // the settled state is no longer the state a rollback would be undoing. - reg.beginDelete("A"); - expect(reg.authorityEpoch("A")).toBe(settled.settledEpoch + 1); - - expect(reg.rollbackSettledDelete(settled)).toBe(false); - // The stale record must not resurrect and must not clobber the newer fence. - expect(reg.get("A")).toBeUndefined(); - expect(reg.authorityEpoch("A")).toBe(settled.settledEpoch + 1); - // Fail closed: the clear is still unpublished, so the id stays quarantined. - expect(reg.isTopicIdAvailable("42")).toBe(false); - - // A record recreated for the same session while the clear is still in flight - // is likewise not the post-settlement state a rollback may undo. - const reg2 = new TopicRegistry({ topics: { A: boundRecord("A", "42", 1, true) }, fences: { A: 1 } }); - const settled2 = requireSettled(reg2.settleDelete("A", "42", reg2.authorityEpoch("A"))); - await reg2.getOrCreateTopic( - "A", - async () => "43", - () => 2, - undefined, - binding("A"), - ); - expect(reg2.authorityEpoch("A")).toBe(settled2.settledEpoch); - expect(reg2.rollbackSettledDelete(settled2)).toBe(false); - expect(reg2.get("A")).toMatchObject({ topicId: "43" }); - expect(reg2.get("A")?.authorityState).toBeUndefined(); - }); - - test("a settled delete keeps its topic id quarantined until the clear is durable", async () => { - const reg = new TopicRegistry({ topics: { A: boundRecord("A", "42", 1, true) }, fences: { A: 1 } }); - const settled = requireSettled(reg.settleDelete("A", "42", reg.authorityEpoch("A"))); - - // Phase 1 drops the record but must not publish routes: the clear lives only - // in memory, so the id is neither adoptable nor routable during the write. - expect(reg.get("A")).toBeUndefined(); - expect(reg.isTopicIdAvailable("42")).toBe(false); - expect(reg.sessionForTopic("42")).toBeUndefined(); - - // An adopt racing the held write is admitted as a record but stays unrouted, - // so nothing is delivered against a clear that may still roll back. - await reg.getOrCreateTopic( - "B", - async () => "42", - () => 2, - undefined, - binding("B"), - ); - expect(reg.sessionForTopic("42")).toBeUndefined(); - - // Phase 2 publishes routes only once the clear is durable. - expect(reg.commitSettledDelete(settled)).toBe(true); - expect(reg.sessionForTopic("42")).toBe("B"); - expect(reg.isTopicIdAvailable("42")).toBe(false); - }); - - test("a refused settlement yields no rollback token, so it cannot restore anything", () => { - // The persisted fence is newer than the record's own authority, so this - // dispatched epoch never owned the fence and settlement must be refused. - const reg = new TopicRegistry({ topics: { A: boundRecord("A", "42", 1, true) }, fences: { A: 2 } }); - expect(reg.settleDelete("A", "42", 1)).toBeUndefined(); - - // Refusal is total: fence, record and quarantine are intact, and no token - // exists for any caller to hand back to a rollback. - expect(reg.get("A")).toMatchObject({ topicId: "42", authorityState: "delete_pending" }); - expect(reg.authorityEpoch("A")).toBe(2); - expect(reg.isTopicIdAvailable("42")).toBe(false); - expect(reg.sessionForTopic("42")).toBeUndefined(); + expect(registry.beginArchive("A", "host", 1)).toBeUndefined(); + expect(registry.get("A")?.authorityState).toBe("archive_exhausted"); + expect(registry.settleArchive("A", "42", max)).toBe(false); + expect(registry.restoreArchiveAuthority(snapshot)).toBe(false); + expect(registry.restoreArchiveFence(snapshot)).toBe(false); }); }); diff --git a/scripts/telegram-daemon-generation-manifest.json b/scripts/telegram-daemon-generation-manifest.json index 4110cce1e3..442b57386a 100644 --- a/scripts/telegram-daemon-generation-manifest.json +++ b/scripts/telegram-daemon-generation-manifest.json @@ -529,9 +529,9 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:createLightweightDaemonSettings": "2b001f6739e0e4277eb93c689d5d6e5d341b006c071f65539a03fdb94d653aa4", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:loadLightweightDaemonSettings": "b6139460042f6ad3128c5be257e63845a6bf318d920e474ced453c214bc08e0a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:ownerPidFromOwnerId": "46691373b2bee01f28f3817a6aa6a7efffe880c2cea337c89155582c98d952bf", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonInternal": "8d9135cabb89a9d022cb22d0aad159d4f1fe1b7c574afc61dadaeaa66b1b7e36", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonInternal": "17f044cb4ec349fbed63d8ea8c916f419137e0c6fd158eac6dac58de3b14db59", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-cli.ts:runDaemonSmoke": "6f085a667aa5c83de46d2d8945fb845c355fcbb43c46872342a44489203a5830", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "13e924c3b28a3e8a16edc1e2adc2bec8a5bc3e1989be1d4b2b637f355d4895a3", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:DAEMON_GENERATION": "51f8d5a4251fffadfb3f592acd3c77b61ab8a8aeff93f3613d255a96aac96b9c", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-contract.ts:NOTIFICATION_PROTOCOL_VERSION": "b99289f651fedcf020d28dbaf6f07dd37e7e4a5f6dc1f5118b872112325f1e81", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:DaemonProcessReference": "c3d13e3670a6245a1250c4ebfcd80a36dd8fc96c67ab64d9f979182bd117bc4e", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon-control.ts:TelegramDaemonController": "7381b51cd968199876bfccd341ce79f1bcd895c9fb3899149394d6c54459f07f", @@ -556,7 +556,7 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramDaemonOwnershipPhase": "e18b8212480f9c8c7d21c3e97b9b4813d445e5afa8f13c16916cc145d0a45339", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramEffectSupervisor": "10188ff7bfc0eb88ccc6fd75aec7e882666cc2e4ff9817ed00815abdaa6de906", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#authorizeLease": "8798e128b9dd7e53788221b7a2e94332cb607e74e467457b8b885911315ff82a", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#leaseAllows": "c83f77e57d8f4199c8b8c39707cd719efe46bfc14f03ac0b42d40711cd9664ad", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#leaseAllows": "6b847aa4b088dc5570a827f7e1d6388186119b9f5cde4e1ba20dbf715cb8d73a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#leaseTokenAllows": "611a62a9f4a8593725463387d4a28d76e426234db8d84600332203aa7c088883", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#revokeCallbackAlias": "18108d99fc8dc7f52abe6174726968682ed5f103fba868ff99096287a7c58926", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:TelegramNotificationDaemon.#socketLease": "17eed4828fbefc7803b7a41ce5034cb50eeddc405f4aadb3bb5fc660f856988d", @@ -565,23 +565,23 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:acquireDaemonOwnership": "d9170218d4a2546136bea55886296727d91dc72c4521beda45546248fba1ed5a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:acquireTransitionLock": "148715ae25e7c78a3176b80bfd609cfc219052e1690108f17a03b7f10a7656f4", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:bindProvisionalDaemonPid": "3d81721d2252b958e3414fd795db69f58002cf74bc4bed92b064b9eef69c1541", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:callBotApi": "8bed0b7f9c77898867a212d4953ae5f93446a44e28733e89f92ebf4f8aaa91b2", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:callBotApi": "21e9b41a3978c06d788d543344a1dc2ee9875c8f50f068bbb65a459888c1814d", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:classifyForeignLiveOwner": "c907f82f6d51c514f36d0309cca46070bb207993d40fb79deea3b4674a7fa81a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:confirmTelegramDaemonSpawn": "b983c7cef93976030409f557da2609f9ff696e15036f40151174aaef72188338", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:createBotApiAdapter": "dbd5fee0de776d6b26329cc732b1a6a03e249ba083573c5384278fffd070ddcc", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:createBotApiPipeline": "29f077f47b0b660ca4f44de5fd2f191bf31bec33657bb2a2cb8d1522f12953f3", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:createSessionRouter": "38a27bc57a0ea787c6f8de884398e82267e7dbd5424cb2ac290ceb0432b5d0d6", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:createSessionRouter": "f63428dcda87586fc502ab1996d9a697642c5c442cfc03bc3c50a25482745363", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:defaultPidAlive": "437658a2f14ac5daa2a3e84dcd637078566e71a88841cd750efab6d48fc94c3f", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:defaultPidIncarnation": "377afc123d25710c634df1fcc7f39a0c24d2034e0c31e8ed407435cc4c55a313", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:dropSession": "7ba0a76fe3db66d45b4d57fe9b81fa60546b69386d4663ab8f1e72e06727d862", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:dropSession": "a68ee59895f5008e7999178cdd5dc979844e7eabb70b1856824a0cb1bc54923f", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ensureTelegramDaemonRunning": "0dbc6e3450ee72827d720cf492b69c4659e2f366d4d2ff4c008cc19f793a5a19", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ensureTelegramDaemonRunningDetailed": "845f8f868eb2b22c1f9964cd88f536634d7ff76eee543ee4feb7518d538b66f6", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:fetchWithRetry": "0bd340153367adbfe58dc51df9fc39f3c163104aa2040ca76cdbb9142eb68ac2", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:handleSessionMessage": "74df293399835d88afec797c843777f7162b8f5ff7a9050e5c4ff2c40b8b73cb", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:handleTelegramUpdate": "5d3b4714c82a219da0ae3832941cdf2fd651423e749dfd3ab586dd5a31d392fc", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:handleSessionMessage": "dd55fdc5941e6218ae86d5d7e3c0c53d0102158982b136bd65145c909520cea7", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:handleTelegramUpdate": "8f1dc6c87dcbb3000c1de03195d2e8eb000f69a36f44f09edbfcbbdf9203f1dd", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:hasSafeDaemonStateShape": "4c015214f5dd344299328312451d3a6a093c91373e50d449c108c1b5f01e5fcb", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:historicalStateSerializer": "52fb9019a96fd386dca753b594b02e16bc888fe18970bfefcba5268823ae6547", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:isCurrentCompatibleOwner": "e4b11600dbaba418c21f33a36326356fc8c115a47f16ac16b3ed4a3a4ed45ec9", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:isCurrentCompatibleOwner": "48100c505602de0f83e8368f24a56d23b75a726625071b138e10c7326c689ba2", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:isFreshLiveOwner": "34bd26ab22c0547b9b53e67b0a93876bc4fe04a85b3e0dc4ef35273f5bbb7fb5", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:isGeneration3ReleaseDaemonState": "5c92ecda22e9c3a8236d3dbf14182f68c7b2d283dbeaf628cd429b1d5d8a51ef", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:isGenerationAbsentParentDaemonState": "8cc4fb60f76663851456a8e185da1a83da071f5a23459cc54abdeb0406b87dd2", @@ -595,15 +595,15 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:legacyOwnershipLockMatchesHandoffState": "2a6493286f4ad890df5dd894b310424c3c2f88c79de5d5f352b8f7a01eb1629c", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:legacyParentHandoffDecision": "cda241aa062c5e81107bced0311ecd61a1a61f2375c880a21843f2bb691cd8f6", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:liveOwnershipLockDecision": "d07f665ce28d32c14a819c5d86f50e0eeeb867762927139ce3d69ab2b8dbbe75", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:loadAliases": "b195f3d1b4eedd5504ce114d9fd482b4f7a9bfb266541a53faadda431e1880b6", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:loadAliases": "c1348937ecc351c98cb9d1f15d3aed4d0d23ff43ca5ad0ea222faf511e9f7337", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:negotiateToolActivityCapability": "aabd8df5afef0a9cb58c7cab515ff07db04ea957a4c8e184c1668e764f1b8d34", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ownerIdentityMatches": "61562f19838b9dac42a5d2393ddde5a20b5f94ae50299fe5629a89ce3cfab13f", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ownerProvenanceMatches": "e2ea91daa6c78c82c6e0358155b47256bdddc3f99636649407fe2ff989659dc3", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ownershipLockIsReclaimable": "62381cb04fe03fe08a2f00ea5e19bb16bfd3eccfec09d4f46d1bbe2a7a0d2290", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ownershipLockMatchesMetadata": "cb942d88efa39dbed7491389d983c225ac9b8f975c9e9e5285de4bbc712cac96", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:ownershipLockMatchesState": "3299ab1e20cab606991311031dea564219e3f0109ddeec2bfbd1e24a02e09c2e", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:persistAliases": "1efb36d4ab19c67c1fa6c5a672ddfbc835f8dac2601bcd72b79a16a06127444e", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:processTelegramUpdate": "dced923f8f32daa689b11488ea80d843da7ac67b207b2205c9ebb54d88490a88", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:persistAliases": "db7e3feb79c128746d9bd6c23c21c921be1a3591ed22e9366f412b8b18e73905", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:processTelegramUpdate": "92e530333f40b4cb9f760a6819c644a6e80711a89fdc0e3711f4e32f84710721", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:readOwnershipLock": "bfbc4c530db64fa5fe94dd083a77083c9b6df52d2c3868e5fe46c009b0d23655", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:rebindOwnershipLock": "a10f91cdde11b10800484b05c7ab991bb7e473523695002fb50b2f38399318ec", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:reclaimDeadDaemonOwner": "00b3b884d746695c68ed962ecddf61000e050b2f87792d3b09182f7b3fb3ad35", @@ -616,16 +616,16 @@ "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:retireProvisionalDaemonOwnership": "d5f45044ea524f0694691bda67f47f9d74eaa5506926b718f40f2613a892d82f", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:revokeCallbackAliases": "42407ae6ce220a36e06f6b74a339b59dc1ec1efd44fa0e4c80fb2cf43d4fb5ba", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:rollbackOwnershipLockRebind": "7e9bc148e69268c393051e0b87417c20ce44824e777d4be050b9e91607c410a1", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:run": "234ec760a43e3322653eb9c1f4ea5bb13131b590134d9d7d29ad326fd69d3944", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:spawnTelegramDaemonOwner": "8c8fc501b466b3d75ed696483821e52023681b05a1c943398d9abe6306c7eef0", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:run": "70a97480625c5c104513a364a01645391abefa5352adb30f6dd21b464fefa68b", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:spawnTelegramDaemonOwner": "bbb56ea3a91bb24592fe8e7128261fd75424a1387b75b295a3e046fec1aba08a", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:startLifecycleControl": "237cf7c7881048e0e4329650567c8a47f597abe43206fe2f29376eb912cd6d1c", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:syncTelegramDirectory": "d056e2d84b39bd98a2c0b5a6f22ab0bb13adcd909c092b2f0636382dca488468", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:syncTelegramFile": "ab8fff161531fedd4e706428de6e1f780d9f30fb2045da818e8d50a3d410fb74", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:toolActivityAuthorityIsCurrent": "bad67a5828c7920c2a4ea1a1327f09038b10828a22ad1238f0292613ca2939bb", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:toolActivityDeliveryIsCurrent": "a0b380fc1234b6e0a69520de252a4f2b477ae4ed14e6f4418329c88b3bebd043", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:toolActivityOwner": "1b177f2cb458e3b78dad3a54e9a7e8e067dadb9ca0011a9e924dd55be702c062", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:topicAuthorityLeaseFromRegistry": "e777b013f21483c50e99264aa7b078ba1f5103efb46825e62948af6b0909688f", - "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:topicLeaseIsCurrent": "3dd527713dbcbb4119df67460674002dd76bb65b68099c5b67ecb2e6635be109", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:topicAuthorityLeaseFromRegistry": "bd43d9469e8b99c70069c2d3a88257c41382aae1fbb0ceb6d52aa6f6a4df085e", + "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:topicLeaseIsCurrent": "2144a0ca1eddee9ae6ca9a9ca5b3547cd20fc44e5e5e7105f68ad0078fdbcece", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:tryCreateOwnershipLock": "774856cf9f50be869cc3d087d78469e6db719a73ca1d8fc57d388dfbad4c0b81", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:unlinkOwnershipLockExactly": "66a077565bd5e957134fec79b18180cbb7b28f97fe10ac7bb0335120007115ee", "telegram:packages/coding-agent/src/sdk/bus/telegram-daemon.ts:unregisterNotificationRoot": "9d55489151fe24866f3da76d549beec50708749848d575c0a7d6e65924611597", From 2a20cdf375ad7c806221f3471b6e019ea21c7c16 Mon Sep 17 00:00:00 2001 From: twoimo Date: Wed, 5 Aug 2026 03:51:40 +0900 Subject: [PATCH 2/2] fix(sdk): classify transition cleanup seam --- .../scripts/generate-sdk-operation-inventory.ts | 2 ++ .../sdk/protocol/operation-inventory.generated.json | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts index 00ae293850..552723c6e2 100644 --- a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts +++ b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts @@ -37,6 +37,8 @@ const LOCKED_EXCLUSIONS: Readonly> = { "agent_session:constructor": "internal accessor/plumbing, not a user-facing control seam", "agent_session:registerToolSessionCleanup": "internal tool lifecycle cleanup registration, not a user-facing SDK control seam", + "agent_session:registerToolSessionTransitionCleanup": + "internal tool transition cleanup registration, not a user-facing SDK control seam", "agent_session:nextToolChoice": "internal accessor/plumbing, not a user-facing control seam", "agent_session:setForcedToolChoice": "internal accessor/plumbing, not a user-facing control seam", "agent_session:getActiveSkillState": "internal accessor/plumbing, not a user-facing control seam", diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index ad00e03e28..5df6d1f52a 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -2571,6 +2571,17 @@ "testIds": "not_applicable" } }, + { + "sourceId": "agent_session:registerToolSessionTransitionCleanup", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal tool transition cleanup registration, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, { "sourceId": "agent_session:getAsyncJobSnapshot", "sourceFile": "packages/coding-agent/src/session/agent-session.ts",