Skip to content

feat(bot-mode): Group Chats survive the authority gateway dying — replicated log and fenced takeover (#97681) - #99047

Merged
teknium1 merged 1 commit into
mainfrom
hermes/hermes-5099e5c2
Aug 31, 2026
Merged

teknium1 merged 1 commit into
mainfrom
hermes/hermes-5099e5c2

Conversation

@teknium1

Copy link
Copy Markdown
Collaborator

Summary

A hosted Group Chat now survives its authority gateway dying: every participant gateway can keep a durable replica of the room's ordered log and continue the room at epoch + 1 with a lineage-proving takeover, while the returning stale host fences itself out. Implements the durability requirement from #97681 on top of the authority foundation merged in #99007.

Changes

  • gateway/hosted_room_replicas.py (new, 570 LOC): replica store in root state.dbingest_page() persists authority-stamped groups.log pages idempotently (gap- and epoch-regression-safe), promote_replica() continues the room locally at epoch + 1 with an authority.claimed claim event carrying the full lineage, demote_room() records authority.lost and fences a returning stale authority so split-brain writes are impossible
  • tui_gateway/methods_groups.py: groups.replicate / groups.replica_state / groups.promote / groups.demote RPC methods; capabilities now advertise log_replication + authority_takeover
  • Promotion requires confirm: true: storage makes takeover atomic and provable; the caller (explicit user action today, a lease/quorum driver later) decides when it is safe — the exact boundary blessed on Bot Group Chats should keep working after Desktop closes #97681

Validation

Check Result
New replica + RPC suites 20 passed (incl. full failover round-trip)
Whole hosted-rooms area 69 passed
E2E: two real gateway stores, real install identities — A hosts 6 events, B replicates in 2 incremental pages, A dies, B promotes with complete history (claim at seq 7), work continues at epoch 2, A returns → demoted, stale write rejected passed
Ruff clean
Stale-base gate 0 behind origin/main

Infographic

Group Chat failover — replicate, host down, takeover epoch +1, stale host fenced

… replication and fenced takeover

Every participant gateway can now keep a durable copy of a hosted room's
ordered log and continue the room when its authority host is gone:

- gateway/hosted_room_replicas.py: replica store in root state.db.
  ingest_page() persists authority-stamped groups.log pages idempotently,
  refusing sequence gaps and authority-epoch regressions. promote_replica()
  continues the room locally at epoch+1 with a lineage-proving
  authority.claimed event; the stale owner is fenced everywhere the claim
  replicates. demote_room() lets a returning stale authority fence itself
  (authority.lost) upon observing a newer epoch, killing split-brain writes.
- tui_gateway/methods_groups.py: groups.replicate / groups.replica_state /
  groups.promote / groups.demote RPC surface. Promotion requires
  confirm=true — storage decides HOW takeover is atomic and provable, the
  caller (user action now, lease/quorum driver later) decides WHEN it is
  safe, matching the boundary blessed on #97681.

Validation: 20 new tests incl. a full failover round-trip (A hosts, B
replicates incrementally, A dies, B promotes with complete history, A
returns demoted and fenced); 69 total across the hosted-rooms area; E2E
with two real gateway stores and real install identities.
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on 6af548a — feat(bot-mode): Group Chats survive the authority gateway dy

⚠️ Warnings

OSV vulnerability scan · View job

6 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.


debug info

CI timings

CI timings · View report · View job

Wall time 4m14s vs 4m15s (-0.4%). 5 job(s) slower, 3 faster, 4 unchanged.

  • OS-specific tests / Windows-only tests: -25.0s
  • OS-specific tests / macOS-only tests: +4.0s
  • Python tests / e2e: +3.0s
  • Python lints / Windows footguns (blocking): -3.0s
  • OSV scan / Scan lockfiles / osv-scan: +3.0s

@alt-glitch alt-glitch added type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Aug 31, 2026
@dokterdok

dokterdok commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

I reproduced four current-head blockers on exact 6af548a1f73f26bca54dd2e23f0d278c37f3ba6c using two real SQLite stores and the production functions.

P1 data loss: a partial replica can be promoted

promote_replica() reads last_seq but not latest_seq and never requires the replica to be caught up (gateway/hosted_room_replicas.py:355-383). I ingested the first 2 events of an authority log whose page reported latest_seq=5, then promoted. The new authority contained only those 2 events plus the claim:

PARTIAL_PROMOTION source_latest=5 stored_before=2 promoted_latest=3 visible_events=3

Promotion must require last_seq == latest_seq, with a monotonic non-regressing latest_seq and no unresolved gap.

P1 integrity: contradictory overlap is silently accepted

Ingest drops every event with seq <= last_seq without comparing it to the stored row (:210-214). Replaying sequence 1 with a different payload returned ingested: 0, caught_up: true. Idempotency must compare the exact canonical event ID/kind/actor/epoch/payload/created-at for every overlap and reject any mismatch.

P1 authority forgery: page stamp is not tied to the event lineage

The caller supplies page.authority; ingest only rejects a lower epoch (:122-156, :205-208). It does not require event epochs to match the stamped authority or an authority.claimed chain. Changing an epoch-1 page stamp to an unrelated gateway at epoch 99 was accepted, then promoted as that gateway's successor at epoch 100:

FORGED_LINEAGE_PROMOTION previous_gateway_id=install:ffff... previous_epoch=99 authority_epoch=100

Because groups.replicate accepts the page verbatim from client params, transport trust does not supply this missing proof. Ingest/promotion need to validate the complete authority-claim chain and bind room identity/membership to authoritative state, not caller prose.

P1 divergent canonical history on demotion

Promotion appends system:authority-claimed:2 at last_seq + 1 (:383-449). The old host's demote_room() independently appends system:authority-lost:2 at its own same next sequence (:528-563). The reproduced logs therefore disagree at sequence 4:

DIVERGENT_SEQ seq=4 old_host_event=system:authority-lost:2 new_host_event=system:authority-claimed:2

The returning host must first ingest the actual promotion claim and converge on that canonical event. A local demotion receipt, if needed, belongs outside the room log; it cannot consume the claim's sequence.

P1 split brain: two survivors can both become epoch 2

confirm: true is not bound to an expected authority/cursor or a unique winner. Two independent replicas of A both promoted successfully: B became (B, epoch 2) and C became (C, epoch 2), and both accepted writes. An explicit recovery operation still needs one globally exclusive decision/fence; a boolean acknowledgement is not that fence.

P1 tombstone resurrection

The replica schema carries no terminal/disband state. Ingesting the complete replay of a real disband_room() and then promoting created a new active room (disbanded_at=NULL) with the old room.disbanded event followed by an authority claim. Promotion must preserve the permanent retired-ID contract and reject a replicated tombstone.

These are storage-contract blockers independent of the later automatic driver. The PR body is also currently stronger than the implementation: no code continuously sends pages or invokes promotion/demotion, so this head provides replica/takeover primitives, not yet a Group Chat that automatically survives authority-host death.

Two claim corrections also need attention: the two new test files collect 16 tests, not the stated 20, and the replica store's 256 MiB per-room allowance bypasses #99007's 16 MiB root-database safety budget during direct SQL promotion.

Recommended gate: add hostile regressions for all six P1 shapes, then run the two-store round trip again with partial pages, duplicate conflicting pages, A -> B -> C claim validation, concurrent B/C takeover, disbanded rooms, crash boundaries, capacity accounting, and old-host convergence onto the exact promoted log.

@dokterdok

Copy link
Copy Markdown
Contributor

Corrective draft is now #99107. It preserves passive replicas but disables unfenced takeover, adds database-enforced room-ID reservation for mixed-version processes, quarantines any #99047 promotion/demotion lineage already written, preserves disband tombstones, validates overlap/coverage, and shares the root-DB byte budget. The current exact safety suite is 84/84; final independent review and CI are in progress.

teknium1 added a commit that referenced this pull request Aug 31, 2026
…sband

Follow-ups on the salvaged #97744 runner:

- tui_gateway/hosted_room_driver.py: HostedRoomRuntime.cancel() treated its
  initial status read as truth, so a task transitioning queued->running (or
  settling) between the read and the state call surfaced a transient
  'running work requires acknowledged two-phase cancellation' /
  StaleTaskError to the caller and failed groups.disband. Deterministic
  repro on the PR head: test_client_event_id_cannot_squat_disband_receipt
  failed 5/5 locally. cancel() now re-reads and re-routes on every
  race-shaped failure (bounded retries), returns already-cancelled tasks
  idempotently, and rejects truly terminal states honestly.
- methods_groups conflict resolution keeps both method sets: the replication
  surface from #99047 (groups.replicate/replica_state/promote/demote) and
  the runner surface from this layer (groups.stop/retry/approve).
- test_groups_replication_methods.py updated to the runner's stricter
  create contract (2-6 profile-backed members, live worker service).
EduardoSolanas pushed a commit to EduardoSolanas/hermes-agent that referenced this pull request Sep 2, 2026
…sband

Follow-ups on the salvaged NousResearch#97744 runner:

- tui_gateway/hosted_room_driver.py: HostedRoomRuntime.cancel() treated its
  initial status read as truth, so a task transitioning queued->running (or
  settling) between the read and the state call surfaced a transient
  'running work requires acknowledged two-phase cancellation' /
  StaleTaskError to the caller and failed groups.disband. Deterministic
  repro on the PR head: test_client_event_id_cannot_squat_disband_receipt
  failed 5/5 locally. cancel() now re-reads and re-routes on every
  race-shaped failure (bounded retries), returns already-cancelled tasks
  idempotently, and rejects truly terminal states honestly.
- methods_groups conflict resolution keeps both method sets: the replication
  surface from NousResearch#99047 (groups.replicate/replica_state/promote/demote) and
  the runner surface from this layer (groups.stop/retry/approve).
- test_groups_replication_methods.py updated to the runner's stricter
  create contract (2-6 profile-backed members, live worker service).
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
…sband

Follow-ups on the salvaged NousResearch#97744 runner:

- tui_gateway/hosted_room_driver.py: HostedRoomRuntime.cancel() treated its
  initial status read as truth, so a task transitioning queued->running (or
  settling) between the read and the state call surfaced a transient
  'running work requires acknowledged two-phase cancellation' /
  StaleTaskError to the caller and failed groups.disband. Deterministic
  repro on the PR head: test_client_event_id_cannot_squat_disband_receipt
  failed 5/5 locally. cancel() now re-reads and re-routes on every
  race-shaped failure (bounded retries), returns already-cancelled tasks
  idempotently, and rejects truly terminal states honestly.
- methods_groups conflict resolution keeps both method sets: the replication
  surface from NousResearch#99047 (groups.replicate/replica_state/promote/demote) and
  the runner surface from this layer (groups.stop/retry/approve).
- test_groups_replication_methods.py updated to the runner's stricter
  create contract (2-6 profile-backed members, live worker service).
dokterdok added a commit to dokterdok/hermes-agent that referenced this pull request Sep 8, 2026
Add versioned passive history and retirement negotiation, exact claim-prefix validation, explicit passive-only grants, and generation-scoped publisher recovery. Retain v1 behavior and fail closed on unsupported lineage, stale grants, retirement races, and bounded-store exhaustion. Reuse the existing authority-history helper and authored authority-claim foundation from NousResearch#99047/NousResearch#105197. No recovery activation or work-record v2 is enabled.
kshitijk4poor pushed a commit that referenced this pull request Sep 8, 2026
…r Desktop closes

Since v2026.8.31 (#99007 gateway-owned room authority, #99047 replica takeover, #99099 gateway-side turn driver) a room whose members all live on one gateway continues without any Desktop attached. The Bot Mode page carries the cross-machine and replication beats but never states this durability, so readers still assume the Desktop drives the room. Adds one bullet under Groups and group chats; groups.capabilities.driver flag per tui_gateway/methods_groups.py.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants