Skip to content

Split into acp-mux core + rooms collaboration layer#72

Merged
lsaether merged 12 commits into
mainfrom
refactor/core-rooms-split
Jun 9, 2026
Merged

Split into acp-mux core + rooms collaboration layer#72
lsaether merged 12 commits into
mainfrom
refactor/core-rooms-split

Conversation

@lsaether

@lsaether lsaether commented Jun 9, 2026

Copy link
Copy Markdown
Owner

Summary

Splits the project into a two-crate Cargo workspace with a compiler-enforced boundary:

  • acp-mux (lib acp_mux, bin acp-mux) — the standalone generic 1→N ACP multiplexer: per-client JSON-RPC id translation, first-writer-wins agent-request fan-in, initialize/session/new caching for late joiners, fs/*/terminal/* safety defaults, plain replay/late-join, and an RFD 533 baseline session/attach/session/detach. Attaches on ?mux=. Carries no rooms/* knowledge and does not depend on the rooms crate.
  • rooms (lib rooms, bin rooms) — the collaboration protocol, implemented as a MuxExtension plugged into the core actor: turns, queue/steer/cancel, presence, segments, rooms/* frames, and _meta.rooms attach enrichment. Attaches on ?room=. Depends on acp-mux.

The boundary is a MuxExtension trait + MuxCtx capability surface in core (core ships a no-op extension). There is now a single multiplexer implementation; the old src/room/state.rs monolith is gone. The core also structurally guarantees only standard ACP ever reaches the agent — the extension can request sanctioned ACP actions but cannot write raw bytes to the agent.

Breaking changes

  • The collaboration layer is renamed amuxrooms, including the wire namespace: amux/* methods → rooms/*, _meta.amux_meta.rooms, and the amuxTurnId field → roomsTurnId. Clients written against the previous amux/* releases must update method names and _meta keys. The lower-level core crate/binary stays acp-mux.

Notes

  • acp-mux is provider-neutral; --agent-cmd launches any stdio ACP agent.
  • Client-contract fixtures and the namespace spec were renamed to rooms/*.

Test plan

  • cargo test --workspace — 145 passing (core: 21 unit + 6 RFD 533 conformance; rooms: 35 unit + 83 integration).
  • cargo clippy --workspace --all-targets -- -D warnings — clean.
  • cargo fmt --all --check — clean.
  • Each commit builds independently (bisectable).

lsaether added 12 commits June 8, 2026 20:32
Introduce a Cargo workspace with two crates and a compiler-enforced boundary:

- acp-mux (lib acp_mux, bin acp-mux): the standalone generic 1->N ACP
  multiplexer. Id translation, first-writer-wins agent-request fan-in,
  initialize/session/new caching, fs/* and terminal/* safety, plain
  replay/late-join, and an RFD 533 baseline session/attach + session/detach.
  Attaches on ?mux=. Carries no rooms/* knowledge and does not depend on the
  rooms crate.
- rooms (lib rooms, bin rooms): the collaboration protocol, implemented as a
  MuxExtension plugged into the core actor. Turns, queue/steer/cancel,
  presence, segments, rooms/* frames, and _meta.rooms attach enrichment.
  Attaches on ?room=. Depends on acp-mux.

The boundary is a MuxExtension trait + MuxCtx capability surface in core
(core ships a no-op extension). There is a single multiplexer implementation;
the old src/room/state.rs monolith is removed.
Add a mock ACP agent (mock_acp_core) and crates/acp-mux/tests/conformance.rs
covering session/attach roster + history policies (full / none / pending_only),
session/detach shape, first-writer-wins permission resolution, and the
load-bearing invariant that the core path never emits a rooms/* frame.
…pace

Update README, ROADMAP, CHANGELOG, and the design docs for the two-crate
workspace. Rename the namespace spec and client-contract fixtures to the
rooms/* namespace (the collaboration layer previously used amux/*), and add
the refactor plan under docs/design/.
Remove the unused `room_id` helper and the empty `permissions` stub module,
then drop the crate-wide `#![allow(dead_code)]` from the rooms crate. With
that mask gone the crate is clean under `clippy -D warnings`, matching the
core crate (any future dead code now surfaces instead of being hidden). The
one remaining allow is a targeted field-level annotation in the registry.
Reflow lines that exceeded the width limit after the amux -> rooms rename
(rooms is one character longer), and format the conformance test. No logic
changes.
The split is complete; the outcome is captured in README, ROADMAP, and
CHANGELOG. Drop the working plan doc and its ROADMAP link.
…e docs

The namespace spec, fixture, and README document rooms/cancel_active_turn as a
JSON-RPC request addressed to the mux, but only the notification shape was
intercepted. A request-shaped cancel (with an id) fell through on_subscriber_request
and was forwarded to the agent, which a standards-compliant agent rejects with
method-not-found — so the turn was never cancelled, violating the
"addressed to the mux, not the agent" contract.

Add a shared cancel_active_turn helper and call it from on_subscriber_request:
return a result ack, or -32002 when there is no active turn (consistent with
the other rooms/* control requests). The notification shape keeps working as a
fire-and-forget alias, so neither shape ever reaches the agent.

Add request-shape tests (active turn -> mux ack and no agent forward; no active
turn -> -32002) that close the coverage gap which hid the mismatch.
…-segment carry

The split rewrote the `full` history filter to key off `replay_generation`
(`generation == 0` -> include everything; else active-segment-only with no
turn-bookend carry). That diverged from the documented contract two ways:

- after a NOTIFICATION-driven sessionId rotation, `replay_generation` stays 0
  (only session/load bumps it), so `full` returned every segment and behaved
  like `full_lineage`;
- after a load-driven rotation, the active-segment-only filter dropped the
  prior-segment `rooms/turn_started` needed to bracket a current-segment
  `rooms/turn_complete`.

Restore the original semantics in `should_include_replay_entry`, driven purely
by per-frame segment tags (`ext_tag`) like the pre-split `history_full`:
include pre-segment bootstrap (tag 0) + active-segment frames + `rooms/turn_*`
lifecycle bookends whose `roomsTurnId` is in the cross-segment carry set. This
is correct after both rotation kinds and never depends on `replay_generation`.
The filter governs the attach response, streamed history, and the legacy
transport replay (via the replay_frame hook) uniformly.

Add a `MOCK_ACP_ROTATE_SESSION_ID` knob to the mock agent (mid-turn
notification rotation) and a regression test asserting `full` carries the
spanning turn's bookends while excluding prior-segment agent chunks, and that
`full_lineage` still includes them. These were unit-tested in the pre-split
monolith; that coverage was lost in the move.
…tion

When an agent notification carried a params.sessionId different from the
established canonical id, the rooms layer rotated its segment but core's
canonical_session_id stayed pinned to the old id. session/attach resolves and
validates against that core canonical id, so after such a rotation a late
attach reported the stale sessionId and rejected the actually-current one with
"session not found". (Pre-existing: the pre-split monolith rotated via
rotate_segment without updating canonical_session_id either.)

Make core authoritative for canonical session-id tracking: when an agent
notification's sessionId differs from the established canonical id, core calls
set_canonical_session_id(..), which updates its own canonical id AND fires
on_canonical_session_id so the extension rotates segments through the existing
path. This fixes the standalone core too. Remove the rooms extension's
independent on_agent_notification rotation so rotation is driven once, through
on_canonical_session_id, for session/new, session/load, and notification-driven
changes alike.

Add a regression test: after a notification-driven rotation, session/attach
reports the new canonical id and accepts it.
…t the gap

The replay store rehydrates the broadcast frames on restart, but not the
segment lineage layered on top, so after a restart only `full_lineage` is
correct (current-segment `full`, the attach snapshot lineage, replayGeneration,
and the resolved sessionId reset until new agent activity). The CHANGELOG and
rooms.md previously claimed segment bookends / lineage / canonical session-id
were restored on restart.

Correct those claims and add a "Known limitation: cross-restart segment
fidelity" section to rooms.md with a concrete failure-mode example (a
multi-day, multi-segment room redeployed nightly, where a reconnecting client
asking for historyPolicy:full gets a near-empty view). Reconstructing segment
state from the persisted rooms/segment_* frames remains a tracked follow-up.
…snapshot shape

The client-contract fixture showed _meta.rooms.connectedClients as
{peerId, peerName, role} (the peer_joined shape), but the attach roster is
ConnectedClient = {clientId, name} (no role). The snapshot block was also stale:
it carried a non-existent `roomId`, omitted the always-present `selfPeer`,
`activeTurn`, `queue`, `pendingPermissions`, `replayBoundarySeq`, and
`replayGeneration` keys, and its segment entry dropped the required
`openedReplaySeq`.

Rewrite _meta.rooms to match what on_attach actually emits (verified against
the rooms attach test and the on_attach builder). Code was correct; only the
fixture had drifted. Fixtures are not validated against the code, which is how
this drifted — adding a fixture-shape test is a worthwhile follow-up.
…gnals coincide

If an agent emits loaded-session session/update frames before its session/load
response, the mux observes the sessionId change first and labels the closed
segment acp_session_id_changed rather than session_load. Clarify that endReason
is diagnostic, not a strict priority contract, so this is expected.
@lsaether lsaether merged commit 3164f54 into main Jun 9, 2026
1 check passed
@lsaether lsaether deleted the refactor/core-rooms-split branch June 9, 2026 15:22
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.

1 participant