Skip to content

fix(telegram): release the topic-id quarantine once a delete settles - #3610

Closed
twoimo wants to merge 1 commit into
Yeachan-Heo:devfrom
twoimo:fix/telegram-topic-settle-quarantine
Closed

fix(telegram): release the topic-id quarantine once a delete settles#3610
twoimo wants to merge 1 commit into
Yeachan-Heo:devfrom
twoimo:fix/telegram-topic-settle-quarantine

Conversation

@twoimo

@twoimo twoimo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Hello, and thank you for the review time on this one.

This is a single-line behavioural fix to TopicRegistry.settleDelete(), carved out of a larger Telegram topic-authority change so it can be judged on its own. It deliberately avoids every file currently owned by #3596.

The defect

A delete_pending record quarantines its topic id so routing fails closed. settleDelete() removes that record once a remote deletion is definite — but it never recomputes the derived routing tables (byTopic, the ambiguous-topic-id set). The quarantine therefore outlives the record that installed it, and the topic id stays unroutable and unadoptable for the remaining life of the daemon process.

The load path already rebuilds those tables, so a restart releases the id. That divergence is what makes the in-memory state unambiguously stale rather than a second valid interpretation: load() is the authoritative definition of routing, and settleDelete() was leaving memory disagreeing with it.

Why it is reachable on current dev

  1. A crash mid-close leaves authorityState: "delete_pending" on disk.
  2. On restart TelegramDaemon.loadTopics()TopicRegistry.load()rebuildInboundRoutes() adds the topic id to the ambiguous set.
  3. reconcilePendingTopicDeletes()deleteTopic() settles a topicOrigin === "user_created" record locally, with no remote delete call.
  4. The user's topic still exists in Telegram. The next adoption of it is refused at the adoption gate with topic adoption refused: intent chat/binding/topic unavailable, and a session that had collided on that id stops receiving inbound replies until the daemon is restarted.

settleDelete is also reached from three other daemon call sites, so the stale quarantine is not confined to the reconcile path.

The change

One functional line — this.rebuildInboundRoutes() after the record is removed — plus an expanded doc comment explaining why. This matches the existing pattern in the same file, where restoreDeleteAuthority() already rebuilds the routes after mutating authority state.

The fail-closed fence is unchanged: records that are still delete_pending are re-added to the quarantine by the rebuild, exactly as the load path does. No new fields, no new API, no widened surface.

TopicRegistry.delete() has the same stale-set shape, but it is documented as local/test cleanup and has no daemon caller, so I left it alone rather than change an unreachable path.

Verification

Both tests were confirmed to fail against byte-exact dev source and pass with the fix (source reverted with git checkout upstream/dev --, re-run, restored, re-run):

=== source reverted to exactly dev ===
(fail) TopicRegistry settled delete > releases the topic-id quarantine so a re-adopted user topic routes inbound
(fail) TopicRegistry settled delete > routes an adopted topic identically before and after a restart reload
 0 pass, 2 fail
=== fix restored ===
 2 pass, 0 fail

Suites run on this exact head:

  • notifications-topic-registry-settle-quarantine.test.ts + notifications-topic-registry.test.ts + notifications-telegram-daemon.test.ts567 pass / 0 fail
  • notifications-telegram-daemon-2960.test.ts, -2960-redteam.test.ts, -self-heal.test.ts15 pass / 0 fail
  • tsc -p packages/coding-agent/tsconfig.json --noEmit — clean
  • biome check — clean

The new tests use only the exported surface (TopicRegistry, sessionForTopic, isTopicIdAvailable, settleDelete, get, getOrCreateTopic, endpointAuthority, serialize); no private access and no as any. The second test pins live routing against post-reload routing, so this specific divergence cannot silently return.

Ownership

Touches topic-registry.ts and one new test file only. Neither appears in #3596's file set, and I have kept clear of file-lock.ts, session/**, the natives crate, telegram-daemon-contract.ts, and the generation manifest.

Happy to adjust anything here, and thank you again for the careful reviews — they have been genuinely useful in narrowing this down to a provable defect.

`TopicRegistry.settleDelete()` removed the `delete_pending` record but never
recomputed the derived routing tables (`byTopic`, ambiguous topic ids), so the
quarantine that the fenced record installed outlived the record itself. The
topic id then stayed unroutable and unadoptable for the life of the daemon
process, even though the load path rebuilds those tables and would have
released it on the next restart.

Reachable on dev: a crash mid-close leaves `delete_pending` on disk;
`loadTopics()` quarantines the id; `reconcilePendingTopicDeletes()` settles a
`user_created` record locally with no remote delete; the user's topic still
exists in Telegram and the next adoption of it is refused.

Recompute the routes after the delete, matching what `restoreDeleteAuthority()`
already does. Records that remain `delete_pending` are re-added by the rebuild,
so the deliberate fail-closed fence is unchanged.
@twoimo
twoimo force-pushed the fix/telegram-topic-settle-quarantine branch from 8176c12 to 257e3ef Compare July 30, 2026 21:21
@twoimo

twoimo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Just a small note for whenever you have time — no rush at all.

This one is now rebased onto 0433be44 and exact-head CI is fully green (16 pass / 0 fail / 6 skipping), so it should be ready whenever it fits your queue.

To make review as cheap as possible, the whole functional change is a single line:

 	settleDelete(sessionId: string, topicId: string): boolean {
 		const record = this.topics.get(sessionId);
 		if (!record || record.topicId !== topicId || record.authorityState !== "delete_pending") return false;
 		this.topics.delete(sessionId);
+		this.rebuildInboundRoutes();
 		return true;
 	}

It follows the pattern already established two methods away in the same file: restoreDeleteAuthority() also calls rebuildInboundRoutes() after mutating authority state.

I also want to be explicit about the two things I deliberately did not do, in case either is the wrong call:

  • I left TopicRegistry.delete() alone even though it has the same stale-#ambiguousTopicIds shape. It is documented as local/test cleanup and has no telegram-daemon.ts caller, so touching it would have added unreachable change.
  • I did not touch file-lock.ts, path_identity.rs, the natives exports, session/**, telegram-daemon-contract.ts, or the generation guard, since fix(session): complete descriptor-bound cleanup and live migration leases #3596 owns those.

The fail-closed fence itself is unchanged: records still in delete_pending are re-added to the quarantine by rebuildInboundRoutes(), so only a settled id becomes routable again.

Thank you for the thorough reviews on the earlier PRs — the pane-id narrowing and dispatch-fence feedback in particular made that change materially better.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exact-head hostile review: REQUEST_CHANGES

Reviewed head: 257e3eff5139fbd9141f239a98aca6c36c3064aa
Exact PR base: 0433be445278113a0b5ca6ab24983a486bf1df71
Current dev checked: 29ddfe08f51039b1bf4e8e373ba06c68d7005d44

P0: 0. P1: 2.

  1. Settlement is not fence-epoch bound. TopicRegistry.settleDelete(sessionId, topicId) checks only topic ID plus delete_pending. A held E1 delete can race a scan/close-started E2 delete for the same session/topic; E1's definite result can delete E2 and release the newer quarantine. Capture the dispatch epoch and require record/current fence equality at settlement; update all four daemon call sites and add an out-of-order E1/E2 regression.

  2. Rollback does not rebuild derived routes. The new settlement rebuild can make a surviving colliding record routable. If the final topic-state persist fails, restoreDeleteFence reinstates the pending collision but does not rebuild byTopic/ambiguity, leaving inbound routing open despite the restored fence. Rebuild on successful fence restoration (or make settle/rollback route-atomic) and add a persist-failure active+pending collision regression.

The one-line happy-path repair is valid but incomplete under the required generation/owner and rollback races. Exact-head CI is terminal green (16 success / 6 skipped), but it does not cover either blocker. Current-dev overlap is only the Unreleased changelog insertion; #3596 overlap is also changelog-only, and issue #3587 remains separate daemon process/poller lifecycle ownership.


[repo owner's gaebal-gajae (clawdbot) 🦞]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants