feat(cp): observer/lobby — 3-phase design + Phase 1 protocol scaffold (PR 2/4) - #1470
feat(cp): observer/lobby — 3-phase design + Phase 1 protocol scaffold (PR 2/4)#1470chaodu-obk[bot] wants to merge 9 commits into
Conversation
e68b8e7 to
2330bba
Compare
This comment has been minimized.
This comment has been minimized.
2330bba to
3f3d181
Compare
This comment has been minimized.
This comment has been minimized.
3f3d181 to
4a290d3
Compare
This comment has been minimized.
This comment has been minimized.
4a290d3 to
b23b309
Compare
This comment has been minimized.
This comment has been minimized.
b23b309 to
718a9f8
Compare
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…9, F40) Two races shared one root: entry removal (cancel, sweep, fail_instance) was not serialized with the two irreversible wire operations - the delegation forward and the result delivery - so correctness depended on winning races the code did not control. F39 - announce/forward critical section. delegate() now holds the in-flight lock across announced=true, the delegation_requested emit, and the forward try_send, re-verifying the entry's generation first. A teardown's terminal event can no longer precede the requested it terminates (both emits serialize through entry removal), and its best-effort cp/cancel can no longer be enqueued before the forward it cancels - the worker can never start work every other party recorded as cancelled. Entries removed before the section runs were never announced: teardown and sweep now gate their observer terminals and synthesized frames on InFlight::announced (capacity release unchanged), and delegate reports the loss to the initiator instead of forwarding. F40 - commit-first completion. complete() now runs peek -> cap -> commit -> emit -> deliver. Only the path that removed the entry may put an initiator-bound terminal on the wire or emit the observer terminal, so the two audiences can no longer record opposite outcomes: a result whose commit loses to a concurrent cancel/sweep/disconnect is dropped undelivered, and the CP now produces at most one initiator-bound terminal per admission (first-terminal-wins stays as client-side defence in depth). A stalled or vanished initiator no longer flips the outcome: the delegation truthfully completed; the disconnected initiator loses the result exactly as if it had died a moment earlier. This also closes the duplicate-delivery telemetry gap (a second result now drops at peek or commit with distinct logs) and the Deliver::Refused stranding window (the entry is already committed; nothing strands). Also: cap DelegateResultParams.error at max_result_bytes alongside result (carried R6-F14); fix doc-comment misattachments introduced by the round-8 refactor (Claim vs ParentRef, truncate_with_marker vs Deliver) and add the missing ParentRef variant docs; update the module lock-hierarchy and terminal-frames documentation. Tests: 4 new regressions (result racing a concurrent cancel is dropped undelivered; teardown of an unannounced entry emits and sends nothing; sweep of an unannounced entry releases capacity silently; oversized error truncated like result); 3 updated to the commit-first contract. cargo test -p openab-cp: 141 passed, 0 failed. clippy --all-targets -D warnings clean; rustfmt clean.
- Document the observer wire contract for external implementers (docs/control-plane.md "Observer surface"): cp/event envelope and the five event kinds, the per-namespace seq client contract (baseline, gap, restart), correlation on (namespace, delegation_id, admission), lifecycle ordering guarantees, terminal asymmetry (completed-with-status vs cancelled-with-by, the control-plane sentinel), best-effort delivery, metadata_only redaction scope, and cp/list_agents including its roster-only recovery scope (carried R4-F14 / R6-F16). - Refresh the stale status block: this slice ships the observer surface, not "streaming lands later" (round-8 docs finding). - Record in the ADR that Phase 1 of the observer endpoint shipped, with its accepted operational limits (best-effort delivery, non-durable seq, no replay), and align the first-terminal-wins sections in the ADR and the client doc with commit-gated delivery (at most one initiator-bound terminal per admission; rule retained as defence in depth). - metadata_only config docs now state the full suppression scope, including cancel reasons and worker-reported error text, and that CP-synthesized diagnostics survive the knob (F43); document DelegationCompleted.error's status-dependent redaction (F44). - Type the three CpEvent admission fields as AdmissionToken to match the wire structs (F50). cargo test -p openab-cp: 141 passed. clippy -D warnings clean; rustfmt clean.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…d-10 yellows) F51 short-term set (landed together, per review consensus): - max_observers_per_namespace (default 16): the per-namespace observer ceiling is enforced atomically at registration (count + insert under one registry write-lock acquisition; refusal is SATURATED with the cap named). Observer fan-out does bounded per-observer work inside the delegation path's in-flight critical section, so the population is now a configured latency budget instead of an operational hope. - max_event_excerpt_bytes validated to 1..=65536: zero silently emptied every excerpt (R4-F17), an oversized cap multiplies work on the delegation path. - The prompt excerpt (a scan of up to max_prompt_bytes of client input) is computed BEFORE the announce/forward critical section; the section now performs only the generation re-check, the announce flag, one bounded emission, and non-blocking sends. - Observer frame drops now log at warn (systemic lobby saturation was invisible at default log levels; R6-F19 facet). - docs/control-plane.md states the fan-out latency tradeoff honestly instead of "observers can never slow the delegation path" (F51 facet e / F56). F62 - teardown must not be able to panic: - fail_instance, sweep_deadlines, and EventHub::emit are reachable from RegistrationGuard's Drop (possibly already unwinding, where a second panic aborts the process) and from the lease sweeper. All frame/event serialization on those paths is now fail-soft: a serialization error drops the frame with an error log instead of panicking (synthesized_frame helper; emit returns on error). RegistrationGuard's must-not-panic doc now matches the implementation, and the panic-teardown regression test attaches an observer so the unwind exercises the full teardown emit surface. Also from round 10: - F55: lifecycle-ordering docs now say the observer records the outcome the CP committed, with the refused/gone-initiator caveat (control-plane.md + ADR). - F57: the stale drop(admission) comment no longer claims fan-out is outside the critical section. - F58: commit_completion doc describes its step functionally instead of a wrong phase number. - F59: cap_result renamed cap_payload (it bounds result AND error). Tests (141 -> 148): concurrent double-complete yields exactly one claim (F52, completes R4-F34); target-side unannounced teardown is silent (F53 symmetric case); dropped/stale results emit nothing with an observer attached and the stalled initiator provably receives no frame (F54, completes R6-F17); a saturated 16-observer crowd does not block delegation (F51 functional floor); registry observer-cap atomicity; config bound validation. cargo test -p openab-cp: 148 passed, 0 failed. clippy --all-targets -D warnings clean; rustfmt clean.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
…d-12 findings) - F63: EventHub::emit now logs ONE aggregated warn per emission (drop count out of observer count) instead of one warn per refused observer, keeping systemic lobby saturation visible at default log levels without multiplying log I/O by observer count on the delegation path; per-observer detail moves to debug. - F64: recorded as an explicit decision instead of an accident - the SATURATED code doc in proto.rs now states the three capacity domains it deliberately covers and why one code suffices (same client reaction; every message names the exhausted bound), and the client doc's SATURATED bullet includes the observer-cap case. Revisit if machine-readable attribution is needed. - F65: dedicated regression pins the precomputed prompt-excerpt path: truncation preserved in a normal namespace, excerpt key absent under metadata_only, secret content nowhere in the frame. - F66: RegistrationGuard::drop doc rephrased - sweep_deadlines is teardown-adjacent (sweeper task), not on the Drop chain; the fail-soft discipline claim now cannot be misread. - Review NIT: register_conn_capped allocates the handle only after the cap check, so a refused registration no longer consumes a handle id. - Reviewer-suggested guard comment on deliver_result: its intentional expect (request path, absorbed by fail-soft teardown) is now documented so the asymmetry with synthesized_frame is not "fixed" blindly. cargo test -p openab-cp: 149 passed, 0 failed. clippy --all-targets -D warnings clean; rustfmt clean.
|
Important CHANGES REQUESTED What This PR DoesAdds Phase 1 of the CP observer/lobby capability: a read-only How It Works
Round-12 DispositionThe delta since round 12 (
FindingsNo new findings this round. Both lanes returned lane-level LGTMs on the delta: no logic, race, lock-ordering, log-leak, or input-validation regressions (the aggregated warn is strictly less revealing than the per-observer warn it replaces). The verdict remains changes-requested solely on the carried backlog below - all 🟡 important, none critical, previously adjudicated as hardening/docs/architecture follow-up material. Carried open backlog (spot-re-verified at this head)
Baseline Check
Addressing External Reviewer FeedbackNo external reviewer has commented on this PR at review time (the requested reviewer has not yet responded). All prior visible comments are earlier rounds of this review, disposed in Round-12 Disposition above. What's Good (🟢)
Three Reasons We Might Not Need This PR
|
CP Observer / Lobby — phased design + Phase 1 scaffold
Review Contract
Goal
Land the complete Phase 1 of the observer/lobby capability on the CP: the
observeragent type (read-only, unconditionally non-delegating), thecp/eventnotification stream with per-namespace sequence numbers,cp/list_agents, and the server-side wiring that emits events at every lifecycle hook (register, disconnect, lease expiry, delegation requested / completed / cancelled, timeout, target disconnect).Non-goals
cp/attach) and Phase 3 (lobby app, Cloudflare DO deployment) — separate PRs.cp/list_agentssnapshot resync.seq.Accepted Residual Risks
seqgap + snapshot resync is the recovery contract, verified by tests but not yet exercised by a real lobby client.seqis not durable across CP restarts; observers treat any regression/reset as a full resync.metadata_onlysuppresses agent-supplied bodies only; CP-synthesized diagnostics (timeout, disconnect reasons) remain visible, which is intentional but means event streams are not fully content-free.Acceptance Criteria
seqis per-namespace monotonic and dense per observer stream (a process-global counter would manufacture false gaps from other namespaces' activity)concurrent_emits_enqueue_in_seq_order(8 threads x 16 events) fails 5/5 under the racy pattern, passes under the shipped onetry_sendon the existing bounded per-connection queues; the delegation path never waits on an observer (saturated-queue test)metadata_only = truenamespaces omit prompt/result excerpts entirelydelegation_requested,delegation_completed(incl. timeout / target_disconnected),delegation_cancelled(withfrom/to)cp/list_agentsreturns the caller's namespace roster to any registered clientcargo test -p openab-cp: 81 passed (54 -> 81, 0 removed); clippy--all-targets -D warningsclean; rustfmt clean for the crateFollow-ups
cp/attachrelay + human identity class (clientsallowlist) — next PR in the stack.cp-coreextraction).session_*relay events once Phase 2 lands (open question below).Motivation
The control plane (ADR:
docs/adr/agent-control-plane.md, implementation: #1469) makes every agent-to-agent delegation flow through one hub — which means the CP is structurally the single point where the whole fleet's activity is visible. Today that visibility exists only as tracing logs.This roadmap turns it into a first-class product surface — a lobby: any authorized client (a macOS/iOS app, a web dashboard,
wscat) connects to the CP and watches, live, who is registered, who delegates what to whom, and how each delegation ends. Phase 2 extends the same connection into a relay, so a human client can talk to any registered agent directly through the CP — no Discord/Slack in the loop, no per-agent URLs.This also answers ADR Open Question #3 (human visibility of delegation traffic): instead of mirroring into platform threads, visibility becomes a protocol-level subscription.
Target architecture (end state, Phase 3)
flowchart TB subgraph clients["Lobby clients (Phase 3)"] APP["macOS / iOS app"] WEB["web dashboard / wscat"] end subgraph cp["openab-cp — native binary or Cloudflare Durable Object"] REG["registry<br/>(who is alive)"] ROUTE["router<br/>(delegate RPC)"] POL["policy<br/>(who → whom)"] EVT["event stream<br/>(Phase 1)"] RELAY["client relay<br/>(Phase 2)"] end subgraph fleet["OAB runtimes (outbound dial, no ingress)"] A["OAB 'koudu'<br/>type=primary<br/>ACP stdio → Agent A"] B["OAB 'worker-1'<br/>type=worker (headless)<br/>ACP stdio → Agent B"] end APP -- "WS: observer<br/>cp/event subscription" --> EVT APP -- "WS: relay session<br/>(Phase 2)" --> RELAY WEB -- "WS: observer" --> EVT A -- "register / WS" --> REG B -- "register / WS" --> REG A <-. "cp/delegate ⇄ cp/delegate_result" .-> ROUTE ROUTE <-. "forward ⇄ result" .-> B RELAY <-. "session/prompt ⇄ session/update" .-> A EVT -. "mirrors registry + delegation lifecycle" .- ROUTEPhases
Phase 1 — Observer protocol (this PR)
A third client type joins
primary/worker:observer— a read-only lobby client, authenticated by the same per-key identity binding as agents.type = "observer"cp/registerfirst-frame rule. Never selectable as a delegation target; can never initiate, serve, cancel, or complete delegations — enforced unconditionally in CP policy, with no relaxation knob by designcp/eventseq(gap detection) +ts+ flattened event payloadagent_registered,agent_deregistered(disconnect / lease-expired),delegation_requested,delegation_completed(all terminal statuses incl. timeout / target_disconnected),delegation_cancelledmax_event_excerpt_bytes(default 4 KiB) — the lobby is an audit surface, not a second delivery pathcp/list_agentsseqgapprodsees nothing fromdevsequenceDiagram participant O as Lobby app (observer) participant CP as openab-cp participant P as OAB primary participant W as OAB worker O->>CP: cp/register (type=observer) CP-->>O: ack {heartbeat, lease} O->>CP: cp/list_agents CP-->>O: roster snapshot P->>CP: cp/delegate {target, prompt, deadline} CP-->>O: cp/event {seq:n, delegation_requested, prompt_excerpt, chain} CP->>W: cp/delegate (forwarded, CP-stamped chain) CP-->>P: ack {assigned_to} W->>CP: cp/delegate_result {status, result} CP->>P: cp/delegate_result CP-->>O: cp/event {seq:n+1, delegation_completed, result_excerpt}Delivery semantics (v1, deliberate): best-effort fan-out over each observer's bounded outbound queue. A slow observer loses frames, detects the
seqgap, and resyncs viacp/list_agents— the CP never buffers unboundedly for a spectator, and observers can never backpressure the delegation path.Phase 2 — Client relay (human ↔ agent through the CP)
Upgrade the CP from agent-to-agent hub to a general session hub: an authorized client opens a relay session to any registered agent by name, through its existing CP connection.
cp/attach {target}→ relayedsession/prompt/session/updateframes (reusing ACP session semantics end-to-end)clientsallowlist (which namespaces/agents they may attach to)delegation-equivalent visibility once Phase 1's event vocabulary is extended withsession_*events (open question below)Rationale for CP-relay over app→OAB direct dial: the app only ever needs one URL + one credential; workers stay ingress-free; policy/audit stay centralized.
Phase 3 — Lobby surfaces + Cloudflare deployment
Two independently shippable tracks:
Lobby app (macOS/iOS, SwiftUI): roster sidebar with live state (registered / working / saturated — borrowing herdr's four-state sidebar density), delegation timeline fed by
cp/event, tap-an-agent-to-chat via Phase 2 relay.Cloudflare Durable Objects deployment: split
openab-cpinto a pure logic core (registry/router/policy/events — no tokio/axum dependency; events in, frames out) with two transport frontends:workers-rs+ one DO per namespace — WebSocket Hibernation for idle connections, DO Alarms for lease/deadline sweeps, zero-ingress global reachThe namespace→DO mapping matches the CP's isolation model 1:1. Agents are agnostic: it's the same
wss://URL either way.flowchart LR subgraph core["cp-core (pure logic crate)"] C["registry · router · policy · events"] end subgraph native["native frontend"] N["axum + tokio<br/>systemd / ECS / k8s"] end subgraph cf["Cloudflare frontend"] D["workers-rs + Durable Object<br/>1 DO per namespace<br/>WS Hibernation + Alarms"] end N --> C D --> CWhat is in this PR (Phase 1, complete)
proto.rs:AgentType::Observer;JsonRpcNotification;EventParams(seq/ts/namespace+ flattenedCpEvent); the fiveCpEventkinds;cp/list_agentstypes (AgentSummary,ListAgentsResult); method namescp/event,cp/list_agentsregistry.rs:select()excludes observers (never a delegation target, even by exact name);observers(namespace)fan-out setpolicy.rs:PolicyDenial::ObserverInitiation— checked before everything else, unconditionalconfig.rs:max_event_excerpt_bytes(default 4096)cp.toml.example: observer identity exampleServer wiring (second commit on this branch):
events.rs(new):EventHub— per-namespace seq streams; one serialization per emission; ordered, non-blockingtry_sendfan-out toregistry.observers(ns); excerpt/metadata_onlypolicy. Seq allocation and enqueue are atomic per namespace (concurrency regression test included).server.rs: registration announce (after ack), disconnect/lease-expiry deregister announce,cp/list_agentshandler, explicit observer guard on the delegation methods.router.rs:delegation_requested/delegation_completed(incl. timeout and target-disconnect terminal paths) /delegation_cancelledemission; sharedtruncate_with_markerreused for excerpts.AgentDeregistered.reasonis now an enum (disconnect/lease_expired);DelegationCancelledcarriesfrom/to;prompt_excerptisOption<String>(omitted undermetadata_only);seqdocumented per-namespace.[namespaces.X] metadata_only = true— events carry lifecycle metadata but no prompt/result content (CP-synthesized diagnostics like timeout reasons remain).Verification
cargo test -p openab-cp: 81 passed, 0 failed (54 at the scaffold commit -> 81; 0 tests removed or renamed).cargo clippy -p openab-cp --all-targets -- -D warnings: clean.cargo fmtclean for the crate.concurrent_emits_enqueue_in_seq_orderfail 5/5 runs; the shipped pattern passes.Open questions
session_*relay events in Phase 2, or is delegation visibility enough for the lobby's v1?agent_registeredevents for observer connections themselves — currently planned to be emitted (the lobby sees other lobbies); trivial to filter if it's noise.