Repository navigation
feat(cp): OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4) - #1471
Conversation
This comment has been minimized.
This comment has been minimized.
2330bba to
3f3d181
Compare
3e246c0 to
9dc41e7
Compare
This comment has been minimized.
This comment has been minimized.
3f3d181 to
4a290d3
Compare
9dc41e7 to
c2030f2
Compare
This comment has been minimized.
This comment has been minimized.
4a290d3 to
b23b309
Compare
c2030f2 to
995b16c
Compare
This comment has been minimized.
This comment has been minimized.
b23b309 to
718a9f8
Compare
995b16c to
68dbe7e
Compare
This comment has been minimized.
This comment has been minimized.
718a9f8 to
08fd30c
Compare
68dbe7e to
a120ea6
Compare
This comment has been minimized.
This comment has been minimized.
08fd30c to
7473568
Compare
Self-Review & CI Verification Summary (Head
|
Independent Deep Systems Review (Model: Gemini Pro Extended Thinking)An adversarial systems-level code review of branch 1. High-Confidence Invariant Verifications (Passed)
2. Architectural Observations & Follow-Up Recommendations
VerdictLGTM for PR #1471 scope. The Round 8 fixes (F46, F51-F55) are verified correct, CI is 100% green at |
…dless doc (review nits)
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Important
CHANGES REQUESTED
Consolidated review: #1471 (comment)
GitHub event: COMMENT -- self-review delivery only; this is not an approval.
| #[derive(Debug, Clone, Deserialize)] | ||
| #[serde(deny_unknown_fields)] | ||
| pub struct ControlPlaneConfig { | ||
| /// CP WebSocket endpoint, e.g. `wss://cp.internal:9800/cp`. The server |
There was a problem hiding this comment.
🟡 F1 - Require an explicit secure transport policy
auth_key is the complete bearer credential, but this endpoint accepts non-loopback ws:// without rejection or an explicit insecure opt-in. That sends the credential in cleartext over the network.
Requested change: reject non-loopback ws:// by default, or require an explicit allow_insecure_transport opt-in with a startup warning and documented local-development exception.
| ..Default::default() | ||
| }; | ||
| let (ws, _resp) = | ||
| tokio_tungstenite::connect_async_with_config(request, Some(ws_config), false) |
There was a problem hiding this comment.
🟡 F2 - Bound the complete dial
connect_async_with_config covers DNS, TCP, TLS, and the WebSocket handshake but has no explicit deadline. A blackholed endpoint can therefore stall this reconnect iteration until the OS timeout, far beyond the documented retry schedule.
Requested change: wrap the dial in a shutdown-aware timeout and add a paused-time regression test.
| return; | ||
| } | ||
| } | ||
| backoff = (backoff * 2).min(MAX_BACKOFF_SECS); |
There was a problem hiding this comment.
🟡 F3 - Jitter fleet reconnects
Every replica follows the same deterministic 1/2/4/8/16/30-second sequence. After a CP restart, the fleet can reconnect in lockstep and create a thundering herd.
Requested change: apply full or equal jitter while preserving the ceiling, and test the allowed delay range.
|
|
||
| /// Number of admitted, not-yet-finished delegations. | ||
| pub fn active(&self) -> u32 { | ||
| self.inflight.lock().expect("inflight mutex").len() as u32 |
There was a problem hiding this comment.
🟡 F4 - Recover this bookkeeping lock from poisoning
All inflight operations use expect("inflight mutex"). If any panic poisons the lock, SlotGuard::drop can panic again while unwinding through release, causing a process abort. The map stores only tokens and Arc<Notify>, so recovery is safe.
Requested change: use an unpoisoned mutex or recover with PoisonError::into_inner() consistently, including the drop path, and add a poison-recovery test.
| }; | ||
|
|
||
| match method.as_str() { | ||
| methods::DELEGATE => { |
There was a problem hiding this comment.
🟡 F5 - Fail closed on the registered role
This branch accepts cp/delegate for both Worker and Primary. That makes hub routing the only role boundary even though the runtime already has the configured role.
Requested change: reject delegation requests unless self.cfg.agent_type == CpAgentType::Worker, and test the primary path.
| /// **Strict.** An unknown key here is a hard startup failure rather than a | ||
| /// silently-defaulted one: a mistyped `max_delegated_sessions` would otherwise | ||
| /// look effective while the runtime advertised the default budget of 1. | ||
| #[derive(Debug, Clone, Deserialize)] |
There was a problem hiding this comment.
🟡 F7 - Redact the credential at its owning type
Deriving Debug includes auth_key verbatim. Header sensitivity protects request formatting only; one future config diagnostic can expose the bearer credential.
Requested change: provide a manual redacted Debug implementation or use a secret wrapper whose Debug is always masked.
| // (visible in the roster, ready for primary-side initiation in the | ||
| // next slice). Facade-only forecloses the client entirely. | ||
| if cfg.control_plane.is_some() { | ||
| HeadlessMode::ControlPlaneWorker |
There was a problem hiding this comment.
🟡 F8 - Name and document the actual full-boot mode
This ControlPlaneWorker result also represents type = "primary" plus [mcp]. The config table still omits that combination and leaves the primary/no-adapter error row unqualified.
Requested change: rename the variant to behavior-oriented FullBoot/ControlPlaneClient, add the primary + [mcp] row, and qualify the error row as primary without [mcp].
|
|
||
| async fn send(sink: &mut WsSink, frame: &JsonRpcRequest) -> anyhow::Result<()> { | ||
| let text = serde_json::to_string(frame)?; | ||
| if text.len() > MAX_INBOUND_FRAME_BYTES { |
There was a problem hiding this comment.
🟡 F9 - Name the bidirectional frame contract accurately
The complete outbound frame is now checked against MAX_INBOUND_FRAME_BYTES, while the constant comment says outbound is already capped elsewhere and this closes only the inbound direction. Payload-field caps and complete-frame caps are distinct.
Requested change: rename this to MAX_FRAME_BYTES (or split the limits) and document its use for both WebSocket receive configuration and serialized sends.
| runner, | ||
| std::time::Duration::from_secs(prompt_hard_timeout_secs), | ||
| )); | ||
| tokio::spawn(client.run(shutdown_rx.clone())) |
There was a problem hiding this comment.
🟡 F10 - Supervise the control-plane task during normal operation
This handle is only awaited during shutdown. If run returns early or panics, the process can remain healthy-looking while no longer registering or serving; a worker-only process has no other duty that exposes the failure.
Requested change: supervise early task exit and fail readiness or terminate worker-only mode so the deployment supervisor can recover it.
| } | ||
| HeadlessMode::None => { | ||
| anyhow::bail!( | ||
| "no adapter configured — add [discord], [slack], [telegram], [wecom], [googlechat], or [gateway] to config (or [mcp] for facade-only mode, or [control_plane] with type = \"worker\" for control-plane worker mode), or set platform env vars (TELEGRAM_BOT_TOKEN, etc.)" |
There was a problem hiding this comment.
🟡 F11 - Keep startup remediation complete
has_unified_platform recognizes LINE, LINE WORKS, and Teams, but this error omits [line], [lineworks], and [teams] from the valid adapter list.
Requested change: include those adapters or replace the brittle enumeration with a reference to the platform configuration documentation.
|
🔒 Auto-closing: this PR has had the If you'd like to continue working on this, please submit a new PR and link to this one if necessary. |
|
Resolved the round-9 findings at The branch now also includes the latest stacked base ( Round-9 disposition
Also removed initiator-supplied cancel-reason text from info logs (only its byte count is recorded). ValidationValidated on macmini against the merged stack and exact resulting content:
New-head review follow-up (
|
|
Important CHANGES REQUESTED What This PR DoesConnects an OAB runtime to the control plane (ADR §3/§4, worker half): a new optional How It Works
Findings
Finding Details🔴 F1: Silent failure misclassified as Completed when tools ranIn let silent_failure = final_content.is_empty() && turn_result.is_silent_failure();
Consequence: a delegated turn that runs one or more tools and then silently fails ( Requested change: derive the silent-failure classification from the raw agent output / 🟡 F2: Result cap is fixed 512 KiB and decoupled from the negotiated frame limit
Requested change: cap against the full serialized frame with headroom for the envelope, and either negotiate the limit from the register ack or validate/enforce a documented shared minimum between CP 🟡 F3: Teardown bound is not under the drain window
Requested change: make 🟡 F4: CP-task-failure return bypasses ordered shutdown and pool.shutdown()
Requested change: route the CP-exit error through the shared cleanup path (record the exit result, run cleanup, then return it), and add a regression test proving 🟡 F5: Redundant outer shutdown arm can win over the inner drain
Requested change: only arm the outer shutdown branch around the dial/register phase, or make the inner 🟢 F6: What is done well
Baseline Check
Three Reasons We Might Not Need This PR
None of these blocks the slice — they are decision context. The strongest case for merging as-is is the real in-process integration coverage; the strongest case against is F1, which should be fixed before this ships. |
|
Important CHANGES REQUESTED What This PR DoesConnects the OAB runtime to the Agent Control Plane (ADR sect.3/sect.4, worker half): an optional How It Works
Round Scope NoteReviewed head Verified fixed this round:
Findings
Finding Details🟡 F25 + F26: Document the delegation context schema
Requested change: document 🟡 F56: Fix the teardown test's assertion message
Requested change: update the message to reference the 4s shared teardown bound (or interpolate Baseline Check
What's Good (🟢)
Addressing External Feedback
5. Three Reasons We Might Not Need This PR
The ADR already chose an in-process client, and the correctness and hardening work this round is verifiably sound; these remain timing and factoring arguments, not verdicts against the feature. |
OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4)
Implements ADR §3 (Registration, OAB side), §3-Headless, and the worker half of §4 —
docs/adr/agent-control-plane.md.Discord Discussion URL: https://discord.com/channels/1491295327620169908/1491365158868619404/1532377620241449040
Review Contract
Goal
An OAB runtime can join the control plane: a new optional
[control_plane]config section makes the runtime dial the CP over WebSocket, register with its key-bound identity, heartbeat, and — whentype = "worker"— serve incomingcp/delegaterequests by running the prompt through its local ACP session pool and replyingcp/delegate_resultwithin the deadline.type = "worker"also unlocks headless mode:[agent]+[control_plane]with no platform adapters is a valid boot.Non-goals
spawn_agent, …) — PR 4/4 (ADR §6).openab agentCLI — PR 4/4.target_disconnected(ADR §4 v1 contract).Accepted Residual Risks
target_disconnectedsynthesis is the single source of truth. Deliberate: per-connection ownership is the ADR's replica-safety rule.stream_prompt_blocks→PromptExecution) touches the shared platform delivery path; safety is argued by mechanical mapping + every pre-existing adapter/dispatch test passing unmodified, not by new platform-level tests.x86_64-pc-windows-gnutarget); no platform-specific code was added and the crate compiles with--no-default-features.Acceptance Criteria
[control_plane]→ zero behavior change (config parse test:None; no task spawned)[agent]+[control_plane type="worker"], no adapters → boots headless;type="primary"alone without adapters still bails;[mcp]-only unchanged;[mcp]+[control_plane]runs facade AND client (run-mode test matrix)openab-cpserver in-process (integration test, not a mock): register/ack, heartbeat keeps lease, delegate→result roundtrip, cancel mid-flight, local deadline →Timeout, lease-expiry close → reconnect + re-register succeedsinstance_idper process across reconnectsdelegation_id→ immediateFailed, never executed, CP capacity released); outcome mapping Completed/Failed/Timeout/Cancelled pinned by unit tests; cancel/deadline cleanup bounded (5s) and the pool session is discarded — no slot leaksCompletedresult bodies are capped (512 KiB, UTF-8-safe marker) below the CP'smax_frame_bytestransport limit — an oversized agent result can no longer drop the connection and kill co-inflight delegationsopenab-cpgains a defaultserverfeature;openab-coredepends on itdefault-features = false—cargo treeshows no axum edge into the runtime; theopenab-cpbinary still builds with default features; zero Dockerfile changes neededauth_keynever enters the agent child env (untouchedenv_cleardiscipline) and never appears in logsFollow-ups
wss://…/acp; the server mounts/cp— example corrected in this PR; revisit if an alias is preferred instead.At a Glance
Prior Art & Industry Research
crates/openab-core/src/gateway.rs).[mcp](feat(mcp): facade-only run mode — adapter-less [mcp] config is valid #1453).AdapterRouter+SessionPool), rather than the ACP-over-WSacp_clientsynthesis path — one seam, no event fabrication.Proposed Solution
crates/openab-cp): defaultserverfeature gates axum/registry/router/policy/events/server + the binary;proto(wire types) stays unconditional. The runtime consumesopenab-cpwithdefault-features = false— wire types only, no server deps, no new Dockerfile stubs.crates/openab-core/src/config.rs):ControlPlaneConfigwithdeny_unknown_fields,CpAgentType(primary|worker—observerunrepresentable by construction),${ENV}expansion free via the existing pass, validation for empty fields andmax_delegated_sessions > 0.crates/openab-core/src/control_plane/client.rs): process-lifetimeinstance_id; Bearer auth at upgrade;cp/registerfirst frame; single select! loop over inbound / heartbeat / completion channel / shutdown; on disconnect cancels local work, drains (5s) then aborts, backs off shutdown-aware.crates/openab-core/src/control_plane/executor.rs): admission (cap, duplicate) decided before execution; fresh ACP session per delegation keyedcontrol-plane:<sha256(instance_id + delegation_id)>; budgetmin(deadline − now, prompt_hard_timeout);PromptRunnertrait seam so integration tests inject a fake runner while production uses the pool.crates/openab-core/src/adapter.rs):stream_prompt_blocksreturnsPromptExecution { final_text, terminal_error, silent_failure }; platform callers map back mechanically — no observable change, pre-existing tests untouched.src/main.rs): client spawned after router construction when the section is present; stopped (or aborted after 10s) beforepool.shutdown(); headless run-mode matrix extended.Alternatives Considered
openab-cp-clientcrate: rejected — every one of the 19 Dockerfiles stubs workspace crates and would need edits; a module insideopenab-coreis Dockerfile-neutral and the client is small.acp_clientevent-synthesis path for delegated prompts: rejected — it fabricates gateway events to re-enter the dispatcher; the cron precedent calls the router/pool seam directly with less indirection and no fake sender identity.Failedso CP capacity releases.Validation
cargo test -p openab-cp— 94 passed (server unaffected by the feature split; binary still builds)cargo test -p openab-core— 713 passed, +36 new (config/executor/client incl. the transport-cap regression); 1 failure is the known pre-existing macOS-onlysecrets::tests::resolve_exec_nonzero_exit, present at baselinecargo test -p openab-core --test cp_client— 7 integration tests against the real in-process CP server: registration, heartbeat/lease, roundtrip, cancel, timeout, lease-expiry reconnect, shutdown deregistrationcargo test(root bin) — 25 passed incl. the headless run-mode matrixcargo clippy --workspace --all-targets— warning count identical to baseline (12);cargo fmt --checkdiff count identical to baseline (217, pre-existing drift untouched)cargo check --workspace --no-default-features— passescargo check --target x86_64-pc-windows-gnu— UNVERIFIED (target not installed on the build host); no platform-specific code added