Skip to content

feat(events): multi-client session synchronization, replay, and an ACP agent surface - #14826

Merged
mrveiss merged 49 commits into
Dev_new_guifrom
research-ahp-acp
Aug 23, 2026
Merged

mrveiss merged 49 commits into
Dev_new_guifrom
research-ahp-acp

Conversation

@mrveiss

@mrveiss mrveiss commented Aug 22, 2026 •

Copy link
Copy Markdown
Owner

Research analysis of two agent protocols, plus the implementation of the multi-client gaps that
audit found.

Closes #14814, #14816, #14817, #14818, #14819, #14820, #14821, #14823, #14825.

Not closed by this PR — partially delivered, see Scope honesty below: #14822, #14824.
Tracked under umbrella #14815.

Thinking Path

The owner asked for research on the Agent Host Protocol (AHP) and the Agent Client Protocol (ACP),
then confirmed the framing that matters: AutoBot is one host with many clients — precisely the
problem AHP exists to solve — and asked for the findings implemented under a single PR.

Phase 1 established that AHP and ACP are not competitors but two layers of one stack; Microsoft's
own guide/ahp-and-acp.md says so outright ("AHP is a coordination layer. ACP is a communication
layer"), with the reference architecture speaking AHP upstream to clients and ACP downstream to
agents.

Phase 2 audited the code rather than reasoning from module names, and reframed the work:
AutoBot already had almost every ingredient — channel-scoped fan-out, monotonic per-channel
event ids, a Redis Streams replay buffer, per-session multi-connection presence — but they were
wired to three different buses, and the main chat WebSocket used the one that was structurally
single-client. Most of what follows is assembly, not invention.

The audit also surfaced a pre-existing critical defect (#14814) with a blast radius across
workflow, chain-of-thought and approval events, plus a silent data-loss path: chat history was
written from inside the WebSocket broadcast callback, so with no client attached nothing was
persisted at all.

Ordering was dependency-driven: fix delivery, make ids durable, then replay, then ownership, then
reconciliation on top of ownership, then convergence, then ACP last — because ACP's session/load
and session/resume need a durable server-side session to resume into.

What Changed

Delivery and persistence (#14814)

  • event_manager.py — the broadcast handler was a scalar, so a second client displaced the
    first and the first disconnect cleared delivery for everyone. Now a set, with add/discard
    semantics, per-callback failure isolation, and automatic unregistration of dead sockets.
  • api/websockets.py — registers and unregisters this connection rather than overwriting a
    global slot; _add_to_chat_history moved out of the broadcast handler.
  • New publish-time persistence hook (register_persistence_hook), wired at startup in
    initialization/lifespan.py, so history is written exactly once per publish and with zero
    clients connected.

Durable sequencing and replay (#14816, #14817, #14818)

  • New events/channel_stream.py — Redis INCR for per-channel sequence allocation (monotonic
    across restarts, safe across workers) and a per-channel Redis Stream as the replay window.
    Degrades to an in-process counter when Redis is unreachable and reports replay as unavailable
    rather than serving a partial history.
  • New constants/event_stream_constants.py — env-var-backed retention/TTL constants, no
    hard-coded values at call sites.
  • events/bus.py — PersistStrategy.REDIS implemented; it previously logged critical and
    dropped the event.
  • live_event_manager.py — event_id now comes from the durable stream, not _event_counters.
  • api/live_events.py — subscribe accepts last_event_id, replays what was missed, and emits an
    explicit resync frame when it cannot produce a complete history.
  • LiveEventService.ts — tracks a high-water mark per channel, replays from it on reconnect, and
    exposes onResync so consumers rebuild instead of appending onto a stale view.

Conversation channels (#14819)

  • session: and chat: added to the channel grammar, with a fail-closed authorization branch
    (_authorize_conversation_channel): admins bypass, the owner is allowed, unknown or unowned is
    denied. Without it any authenticated caller could have subscribed to anyone's conversation.

Host-authoritative session state and reconciliation (#14820, #14821)

  • New api/session_events.py — publishes session/chat mutations durably; wired into the create,
    update and delete endpoints in api/chat_sessions.py and into chat_history/messages.py
    add_message.
  • useChatStore.ts — createServerSession() adopts a server-assigned id; pendingMessageIds +
    confirmMessage / rejectMessage / applyServerSnapshot / applyRemoteMessage implement
    confirmed/pending/optimistic with server-wins conflict resolution.
  • New useSessionSync.ts — subscribes a conversation's channels, applies remote messages,
    confirms our own echoes, and rebuilds from the REST snapshot on a resync directive.

Doctrine (#14823)

  • New docs/developer/EVENT_STATE_DOCTRINE.md — principles, design tests and anti-goals, adapted
    from AHP's guide/doctrine.md, plus a rules-with-teeth table tying each rule to the issue that
    produced it. Linked from the CLAUDE.md trigger table.

ACP agent surface (#14825)

  • New acp/ package — protocol.py (wire vocabulary), transport.py (line-delimited JSON-RPC
    over stdio), server.py (dispatch, lifecycle, client callbacks), runner.py (bridge to
    ChatWorkflowManager.process_message_stream), __main__.py (python -m acp).
  • Serves initialize, session/new, session/prompt, session/cancel, streams session/update,
    and calls back on session/request_permission. Permission handling fails closed: rejection,
    cancellation, a malformed answer and a transport error all deny.

Convergence (#14822, partial)

  • GlobalWebSocketService.ts moved from /api/ws to /api/ws/live, subscribing to global and
    unwrapping channel frames into the legacy flat shape so existing listeners keep working.
  • PersistStrategy.NONE now also fans out to the channel. This is behaviour-preserving, not a
    widening
    : NONE events already reached WebSocket clients through the broken global slot, so
    without this the frontend migration would have silently dropped every workflow, CoT and approval
    event.
  • Heartbeat frames corrected to action-shaped — the legacy type-shaped ping was inert against
    the channel socket.
  • /api/ws marked deprecated in its docstring.

Scope honesty

Two issues are not closed here, because partial delivery must not close an issue.

#14822 — repointing done, retirement outstanding.
Every in-repo reference now targets the channel socket: a new liveEventsUrl getter on
AutoBotConfig (with websocketUrl marked @deprecated), LiveEventService reading it instead of
appending /live by hand, frontend_config.py advertising /api/ws/live, plus network.ts and
project_state_manager.py. One reference was deliberately left alone: the auth allowlist entry is
startswith-matched, so /api/ws already covers /api/ws/live — narrowing it would have silently
started rejecting the legacy route. Outstanding: deleting the route itself,
register_ws_broadcast, and the EventManager broadcast registry. That needs deployed evidence
nothing external still consumes it, which a PR cannot produce.

#14824 — blocker found and cleared; the migrations remain.
The original plan ("~9 routes removed, consumers repointed") was wrong, and measuring showed why:

Bidirectional (client sends commands) analytics ×2, monitoring, startup, long-running-operations, knowledge-research, transcripts, workflow-automation, analytics-quality — 8 of 9
One-way push process-management — 1

/ws/live accepted only subscribe, unsubscribe and ping. Migrating any bidirectional route
onto it would have silently dropped half its behaviour — no way for a client to start a research
run, pause an operation, or change a metrics interval.

So this PR adds the missing primitive instead of migrating blind: events/channel_commands.py, a
{action:"command", channel, command, payload} envelope routed by channel prefix to a registered
handler. Its notable property is that _authorize_channel was extracted so subscribe and command
share one authorization rule
— a second copy is how one path ends up weaker than the other — and
authorization is checked before handler lookup, since whether a handler exists is itself
information about what the deployment runs.

The nine route migrations are still to do, and remain incremental by their own acceptance criteria.

Verification

Tests added — 51 across six files, each driving a production entry point and asserting at the
far boundary rather than on a mock's call count:

File Covers
event_manager_multiclient_14814_test.py two clients both receive; one disconnect does not silence others; persistence with zero clients; persistence exactly once with two; failing callback dropped without blocking peers; None registration rejected
events/channel_stream_replay_14818_test.py ids monotonic; ids survive a restart; two workers never collide; replay after marker; gap beyond retention demands resync; no Redis demands resync; corrupt entry demands resync; first subscribe is not a gap
events/bus_channel_fanout_14822_test.py NONE reaches the channel (the migration-safety property); MEMORY skips in-process listeners; REDIS publishes durably instead of dropping
acp/server_test.py version negotiation never over-claims; capabilities do not advertise unimplemented features; lifecycle enforced; absolute-cwd required; full turn streams and ends; notifications unanswered; permission denial on reject, cancel and transport error
events/channel_commands_14824_test.py command reaches its prefix handler; unauthorized caller refused and handler never runs; authorization checked before handler lookup; unknown prefix refused; malformed refused; handler exception becomes a refusal; handler's own refusal reason survives
useChatStore.reconciliation.test.ts pending on add; confirm; id rewrite; rejection reverts; confirmMessage returns false for a foreign id; remote apply; own echo correlates instead of duplicating; snapshot replaces and clears pendings

Static checks run locally: python -m py_compile across all 21 changed Python files — clean.
No circular import introduced (events/__init__ pulls only stream_manager and types; nothing
imports back into bus or live_event_manager).

Not run locally, and why: the Python suite and the frontend type-check/lint were not executed
here. This repository's standing rules are that code is not run from the codebase and nothing is
installed into it — autobot-frontend/node_modules is absent and installing it is not permitted.
CI is the verifier for both. Reviewers should treat CI as the gate on this PR, not my say-so,
and note that the Python suite is not a required check, so its result needs to be read
deliberately rather than inferred from an overall green.

Behavioural claims I have NOT verified on a host: that a second browser tab now receives
events, and that a reconnect replays the gap. Both are covered by unit tests at the seam, but
neither has host evidence. #14822's retirement in particular is gated on exactly that evidence.

Model Used

Claude Opus 5 (1M context).


Issues from this audit

Tracked under umbrella #14815 (all 11 registered as its sub-issues).

Sources

Full analysis: docs/research/agent-host-and-agent-client-protocols.md.

@mrveiss mrveiss changed the title docs(research): AHP and ACP analysis, and a multi-client audit of AutoBot's event/state layer feat(events): multi-client session synchronization, replay, and an ACP agent surface Aug 22, 2026
@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No hardcoded values detected that have SSOT config equivalents!

github-actions Bot and others added 6 commits August 22, 2026 18:31
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
mrveiss and others added 3 commits August 23, 2026 11:19
Applied Black, isort, and autoflake to match code-quality checks.
Triggered by workflow auto-fix-formatting.yml.
@mrveiss

mrveiss commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

CI note: Unit & Integration Tests went red on two timeouts in files this PR does not touch —
WorkflowCanvas.connections.test.ts and i18n/rtl.spec.ts. Both are Test timed out in 10000ms,
not assertion failures, and both passed on this same branch hours earlier at 11476ms and 8440ms
respectively. They sit on the timeout boundary and tip over under runner load.

Filed as #14854 with the timings. Failed jobs re-run; not treating this as a blocker on this PR's
own changes, and not raising the global timeout to make it go away.

@codecov

codecov Bot commented Aug 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

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