Skip to content

fix(notifications): clear the disconnect-grace deadline when a topic is archived - #3910

Closed
yazzang-homelab wants to merge 1 commit into
Yeachan-Heo:devfrom
yazzang-homelab:fix/archive-clears-disconnect-grace-deadline
Closed

fix(notifications): clear the disconnect-grace deadline when a topic is archived#3910
yazzang-homelab wants to merge 1 commit into
Yeachan-Heo:devfrom
yazzang-homelab:fix/archive-clears-disconnect-grace-deadline

Conversation

@yazzang-homelab

Copy link
Copy Markdown
Contributor

What

Every transition out of disconnect_grace now clears the record's disconnectGraceExpiresAt, and a registry that already carries one loads with it normalized away instead of failing every publish.

Why

parseTopicRegistryState rejects a topic record that carries disconnectGraceExpiresAt in any state other than disconnect_grace:

if (
    raw.authorityState === "disconnect_grace"
        ? raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined
        : raw.disconnectGraceExpiresAt !== undefined
)
    malformed();

Only the grace-to-active transitions deleted it (acquireLease, clearOrphaned, and the endpoint-restore path). Every archive transition left it: beginArchive, restoreArchiveFence, scheduleArchiveRetry, the exhausted-to-pending retry inside archivePendingSessionIds, settleArchive, and the restore path that retires a rebound topic. So the moment an orphaned topic began archiving, the daemon published a snapshot its own parser refuses.

The resulting failure is permanent, not a transient blip. After that publish, loadTopics and every compareAndSet throw, persistTopics converts it to shared topic authority unavailable, and on a shared-authority daemon that rejection exits the process.

Observed on a live installation, not inferred. gjc-crash.log recorded three consecutive daemons dying on the same line within three hours:

2026-08-05T23:14:23.672Z pid=732571 [Unhandled Rejection] Error: shared topic authority unavailable
2026-08-05T23:16:17.656Z pid=735076 [Uncaught Exception]  Error: shared topic authority unavailable
2026-08-06T01:42:00.828Z pid=799009 [Uncaught Exception]  Error: shared topic authority unavailable

Running parseTopicRegistryState against that installation's telegram-topics.json throws malformed Telegram topic state, and bisecting the record isolates one field:

only topic 019fd31c-…: THREW Error: malformed Telegram topic state
removing disconnectGraceExpiresAt -> ok

The record is authorityState: "archive_pending" and still holds disconnectGraceExpiresAt: 1785971807162. The topic sat in archive_pending for hours with no owner to archive it or answer in it — an empty Telegram thread that never goes away.

#3907 stopped that rejection from killing the daemon. This is the reason the rejection happened at all; the two are independent and both are needed. Without this fix a #3907 daemon survives but still cannot ever publish registry state.

Why the parser is relaxed in only one direction. A disconnect_grace record missing its own deadline or orphan observation is genuinely ambiguous about how long the grace runs, so that stays fatal. The reverse is not ambiguous: the authority state is explicit and the stale deadline is inert. Keeping it fatal permanently bricks every installation that already published one, since the daemon cannot load, cannot repair, and cannot publish. Normalizing on load un-bricks those installations and preserves the round-trip invariant serialize -> parse that compare-and-set depends on.

Testing

Three new tests in notifications-topic-registry.test.ts, verified failing on dev (the first two) and passing here:

  1. grace -> archive_pending -> inactive round-trips through parseTopicRegistryState at each step, and the deadline is gone from both settled snapshots.
  2. The real poisoned on-disk record (values copied from the failing installation) parses, normalizes with the deadline dropped, and the normalized snapshot re-parses — which is exactly what compareAndSet validates.
  3. A disconnect_grace record without its own deadline still throws malformed Telegram topic state.

Other verification:

  • bun test packages/coding-agent/test/notifications-topic-registry.test.ts packages/coding-agent/test/notifications-telegram-daemon.test.ts → 611 pass, 0 fail.
  • Against the live poisoned file: parses (5 topics), new TopicRegistry(parsed).serialize() re-parses, and the offending field is undefined on the settled record.
  • bun scripts/telegram-daemon-generation-guard.tsv43 no protected changes (no DAEMON_GENERATION bump owed; the wire contract is unchanged).
  • tsc --noEmit -p packages/coding-agent/tsconfig.json clean; biome check clean on both touched files.

GJC verdict

gajae.pr-review-verdict.v1 needs-human sha256:a3655154acac7fb20629303e9d275d8260ad68d0 reviewer:human evidence:local bun test packages/coding-agent/test/notifications-topic-registry.test.ts (57 pass; 2 of the new tests fail on dev) + live telegram-topics.json parse bisect quoted above

  • Target branch is dev
  • bun check passes (full bun check not run locally; focused suites, typecheck, biome, and the daemon generation guard are green — CI covers the rest)
  • Tested locally
  • CHANGELOG updated (if user-facing)
  • Verdict above matches the exact PR head, not an earlier commit

@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

Exact-head evidence for review — head a3655154acac7fb20629303e9d275d8260ad68d0:

  • Contains current dev (b621997ee792bedf1c24870153d0c5cb09561a5a); GitHub reports MERGEABLE / CLEAN. 16 checks green, 6 skipped, 0 failing.
  • bun test packages/coding-agent/test/notifications-topic-registry.test.ts — 57 pass, 0 fail, 205 assertions.
  • Every remaining record.authorityState = assignment in topic-registry.ts now goes through #setAuthorityState; no transition out of disconnect_grace can leave the deadline behind.
  • The parser stays fatal in the ambiguous direction (a disconnect_grace record missing its own deadline or orphan observation still throws) — pinned by the third new test.

Self-approval is BLOCK per the PR template, so this needs an independent architect/critic/human verdict. Flagging priority: the failure this fixes is permanent once persisted — the daemon can no longer parse its own registry, so every subsequent loadTopics / compareAndSet throws and a shared-authority daemon exits on startup. Three consecutive daemons died that way within three hours on a live installation. Installations already carrying a poisoned record only recover via the load-time normalization in this PR.

@yazzang-homelab
yazzang-homelab force-pushed the fix/archive-clears-disconnect-grace-deadline branch from a365515 to ebba2ca Compare August 6, 2026 09:32
@yazzang-homelab

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (1f735b7bc6bb1fc62c782496f2e2ab9b02adec2a). Supersedes the evidence in my previous comment, which cited a stale dev.

New exact head ebba2ca684b8f2382b4d5468d7dd58fd6307cc42:

  • Contains current dev; MERGEABLE / CLEAN. 16 checks green, 6 skipped, 0 failing.
  • Rebase was content-free: one commit replayed with no conflicts, no fixups.
  • bun test packages/coding-agent/test/notifications-topic-registry.test.ts on the rebased tree — 57 pass, 0 fail, 205 assertions.

Still needs an independent architect/critic/human verdict (self-approval is BLOCK).

…is archived

`parseTopicRegistryState` rejects a record that carries
`disconnectGraceExpiresAt` in any state other than `disconnect_grace`, but
only the grace-to-active transitions deleted it. Every archive transition
(`beginArchive`, `restoreArchiveFence`, `scheduleArchiveRetry`, the
exhausted-to-pending retry, `settleArchive`, and the restore path that
retires a rebound topic) left it in place, so the daemon published a
snapshot its own parser refuses.

The failure is permanent, not transient: after the poisoned publish, every
`loadTopics` and every `compareAndSet` throws `shared topic authority
unavailable`, which on a shared-authority daemon exits the process. On a
live installation three consecutive daemons died that way within three
hours, and the orphaned topic stayed in `archive_pending` for hours with no
owner to archive it or answer in it.

Both halves are needed: transitions out of grace now go through one setter
that drops the deadline, and a registry that already carries one loads with
it normalized away instead of failing every publish forever. The reverse
inconsistency — a `disconnect_grace` record missing its own deadline or
orphan observation — is genuinely ambiguous and stays fatal.

Lore-id: 7e41c0b8
Constraint: a settled authority state must round-trip through its own parser
Rejected: relax the parser for both directions | a grace record without a deadline is ambiguous and must stay fatal
Rejected: repair on write only | already-poisoned installations would never start a daemon again
Confidence: high
Scope-risk: narrow
Reversibility: easy
Tested: grace to archive_pending to inactive round-trips through parseTopicRegistryState; the real poisoned on-disk record loads normalized; a grace record without its deadline still throws
@Yeachan-Heo
Yeachan-Heo force-pushed the fix/archive-clears-disconnect-grace-deadline branch from ebba2ca to a62cd9a Compare August 6, 2026 11:26
@Yeachan-Heo

Copy link
Copy Markdown
Owner

Closing this contribution under maintainer direction. Do not open further PRs or issues in this repository without explicit maintainer approval.


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

@Yeachan-Heo Yeachan-Heo closed this Aug 6, 2026
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