Skip to content

feat(cp): OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4) - #1471

Open
chaodu-agent wants to merge 8 commits into
feat/cp-observerfrom
feat/cp-runtime-client
Open

feat(cp): OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4)#1471
chaodu-agent wants to merge 8 commits into
feat/cp-observerfrom
feat/cp-runtime-client

Conversation

@chaodu-agent

@chaodu-agent chaodu-agent commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4)

Stacked on #1470 (feat/cp-observer). PR 1/4 (#1469) is the CP hub; PR 2/4 (#1470) the observer/lobby protocol; this slice connects the OAB runtime itself to the CP. PR 4/4 (MCP facade tools: spawn_agent / check_delegation / list_agents / cancel_delegation + primary-side initiation) follows.

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 — when type = "worker" — serve incoming cp/delegate requests by running the prompt through its local ACP session pool and replying cp/delegate_result within the deadline. type = "worker" also unlocks headless mode: [agent] + [control_plane] with no platform adapters is a valid boot.

Non-goals

  • Primary-side initiation and the agent-facing MCP facade tools (spawn_agent, …) — PR 4/4 (ADR §6).
  • The Unix domain socket / openab agent CLI — PR 4/4.
  • Durable delegation state across runtime restarts: in-flight work dies with the process; the CP synthesizes target_disconnected (ADR §4 v1 contract).
  • Kiro-style session primitives (inbox, interrupt, broadcast) — explicitly deferred from v1 in the ADR.

Accepted Residual Risks

  • A worker that loses its CP connection abandons in-flight delegations without notifying anyone locally; the CP's target_disconnected synthesis is the single source of truth. Deliberate: per-connection ownership is the ADR's replica-safety rule.
  • The prompt seam refactor (stream_prompt_blocksPromptExecution) 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.
  • Windows cross-check is UNVERIFIED (build host lacks the x86_64-pc-windows-gnu target); no platform-specific code was added and the crate compiles with --no-default-features.

Acceptance Criteria

  • Absent [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)
  • Client registers against the REAL openab-cp server 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 succeeds
  • Reconnect: exponential backoff 1/2/4/8/16/30s, shutdown-aware; backoff resets only after a ≥60s session (no reconnect storm against an accept-then-close CP); one instance_id per process across reconnects
  • Executor: local concurrency cap enforced (over-cap / duplicate delegation_id → immediate Failed, 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 leaks
  • Completed result bodies are capped (512 KiB, UTF-8-safe marker) below the CP's max_frame_bytes transport limit — an oversized agent result can no longer drop the connection and kill co-inflight delegations
  • openab-cp gains a default server feature; openab-core depends on it default-features = falsecargo tree shows no axum edge into the runtime; the openab-cp binary still builds with default features; zero Dockerfile changes needed
  • CP auth_key never enters the agent child env (untouched env_clear discipline) and never appears in logs
  • Full matrix on the build host: openab-cp 94 passed; openab-core 712 passed (+35 new; 1 pre-existing macOS-only failure unrelated); root bin 25 passed; integration 7 passed; clippy and rustfmt at exact pre-change baselines

Follow-ups

  • PR 4/4: MCP facade tools + Unix socket + primary-side initiation (ADR §6).
  • ADR §3 example URL said wss://…/acp; the server mounts /cp — example corrected in this PR; revisit if an alias is preferred instead.
  • Consider surfacing delegation-serving activity in the runtime's own logs/metrics once the fleet runs this (observability today is CP-side via feat(cp): observer/lobby — 3-phase design + Phase 1 protocol scaffold (PR 2/4) #1470's lobby events).

At a Glance

[control_plane]                     openab (runtime)                    openab-cp (hub)
url / auth_key / namespace /   ┌──────────────────────────┐       ┌──────────────────────┐
name / type / labels /         │ control_plane::client    │──wss─►│ register → ack       │
max_delegated_sessions    ───► │  dial → register → serve │◄──────│ heartbeat lease      │
                               │  backoff 1..30s, 1 uuid  │       │                      │
absent section = no client,    │ control_plane::executor  │◄──────│ cp/delegate forward  │
existing deployments untouched │  cap check → session per │──────►│ cp/delegate_result   │
                               │  delegation → ACP pool   │       │ (→ lobby cp/event)   │
[agent]+[control_plane worker] │  cancel/deadline bounded │       └──────────────────────┘
= valid headless boot          └──────────────────────────┘

Prior Art & Industry Research

  • The reconnect/backoff loop mirrors the existing standalone gateway WS client (crates/openab-core/src/gateway.rs).
  • The headless run-mode gate extends the facade-only precedent introduced with [mcp] (feat(mcp): facade-only run mode — adapter-less [mcp] config is valid #1453).
  • The delegation executor reuses the prompt path cron jobs already exercise (AdapterRouter + SessionPool), rather than the ACP-over-WS acp_client synthesis path — one seam, no event fabrication.

Proposed Solution

  1. Feature split (crates/openab-cp): default server feature gates axum/registry/router/policy/events/server + the binary; proto (wire types) stays unconditional. The runtime consumes openab-cp with default-features = false — wire types only, no server deps, no new Dockerfile stubs.
  2. Config (crates/openab-core/src/config.rs): ControlPlaneConfig with deny_unknown_fields, CpAgentType (primary|workerobserver unrepresentable by construction), ${ENV} expansion free via the existing pass, validation for empty fields and max_delegated_sessions > 0.
  3. Client (crates/openab-core/src/control_plane/client.rs): process-lifetime instance_id; Bearer auth at upgrade; cp/register first frame; single select! loop over inbound / heartbeat / completion channel / shutdown; on disconnect cancels local work, drains (5s) then aborts, backs off shutdown-aware.
  4. Executor (crates/openab-core/src/control_plane/executor.rs): admission (cap, duplicate) decided before execution; fresh ACP session per delegation keyed control-plane:<sha256(instance_id + delegation_id)>; budget min(deadline − now, prompt_hard_timeout); PromptRunner trait seam so integration tests inject a fake runner while production uses the pool.
  5. Prompt seam (crates/openab-core/src/adapter.rs): stream_prompt_blocks returns PromptExecution { final_text, terminal_error, silent_failure }; platform callers map back mechanically — no observable change, pre-existing tests untouched.
  6. Lifecycle (src/main.rs): client spawned after router construction when the section is present; stopped (or aborted after 10s) before pool.shutdown(); headless run-mode matrix extended.

Alternatives Considered

  • New openab-cp-client crate: rejected — every one of the 19 Dockerfiles stubs workspace crates and would need edits; a module inside openab-core is Dockerfile-neutral and the client is small.
  • Reusing the ACP-over-WS acp_client event-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.
  • A cargo feature for the client: rejected — the optional config section is already the opt-in; a feature would double the build matrix for no isolation gain (the client adds no heavy deps).
  • CP-side-only concurrency accounting: rejected — the runtime also enforces its cap locally so a CP bug cannot flood a worker; over-cap arrivals get a terminal Failed so 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-only secrets::tests::resolve_exec_nonzero_exit, present at baseline
  • cargo 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 deregistration
  • cargo test (root bin) — 25 passed incl. the headless run-mode matrix
  • cargo clippy --workspace --all-targets — warning count identical to baseline (12); cargo fmt --check diff count identical to baseline (217, pre-existing drift untouched)
  • cargo check --workspace --no-default-features — passes
  • cargo check --target x86_64-pc-windows-gnu — UNVERIFIED (target not installed on the build host); no platform-specific code added
  • Independent audit round: four blocking findings (facade-only foreclosing the client, backoff reset storm, unbounded cancel before discard, non-abortable shutdown) — all fixed and re-verified

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

@chaodu-obk

This comment has been minimized.

…teardown

Round-7 review fixes:

- F1: failed() now caps every error string (64 KiB, UTF-8-safe) inside the
  constructor so no call site can bypass it; oversized-error regression test
  mirrors the success-path one
- F34: delegation_session_key mixes in the CP admission token, so a
  re-admission of a reusable id can never resume an earlier admission's
  session (e.g. one orphaned by a drain-timeout abort); false reconnect
  claim in the doc corrected; key-property test pins admission scoping
- F22/F31: duplicated cancel+discard blocks extracted into bounded helpers;
  discard now shares the 5s bound instead of being unbounded on every
  terminal path
- F15: client WS now sets max_message_size/max_frame_size (1 MiB),
  mirroring the CP's accept-side max_frame_bytes instead of tungstenite's
  64 MiB default
- F5: the cp/register ack wait is bounded (10s, mirroring the CP's
  register_timeout_secs) so a stalled CP enters backoff instead of hanging
- F27: config/docs no longer claim a local min() the code does not
  implement — the runtime enforces the ack value
- F37: ADR section 7 facade label corrected from PR 3/4 to PR 4/4
@chaodu-obk

This comment has been minimized.

@github-actions github-actions Bot added pending-maintainer pending-contributor closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. and removed pending-contributor pending-maintainer labels Aug 14, 2026
@github-actions

Copy link
Copy Markdown

Caution

This PR has been waiting on the author for more than 2 days (labeled pending-contributor since 2026-08-14).
It will be automatically closed in 24 hours if there is no update.

@chaodu-agent — You must add a new comment on this PR to remove the closing-soon label and keep it open. Pushing commits alone is not sufficient. Feel free to reopen a new PR later if it gets closed and you want to pick it back up.

@github-actions

Copy link
Copy Markdown

🔒 Auto-closing: this PR has had the closing-soon label for more than 1 days without activity from the author.

If you'd like to continue working on this, please submit a new PR and link to this one if necessary.

@github-actions github-actions Bot closed this Aug 18, 2026
@thepagent

Copy link
Copy Markdown
Collaborator

reopened

@thepagent thepagent reopened this Sep 3, 2026
…er success discard

Round-8 review fixes:

- F46: cap_text and cap_result now evaluate JSON-escaped byte length
  instead of raw UTF-8 length so control-character and quote/slash escape
  inflation cannot inflate a 512 KiB result past the CP's pre-parse 1 MiB
  max_frame_bytes limit; regression test pins escape-heavy and quote-heavy
  inflation safety; client::send() adds an outbound transport size guard
- F52: cancel_and_discard() now shares a single 5s deadline across both
  the cancel and discard phases (discard uses whatever budget remains,
  or is skipped if cancel consumed the bound), keeping total teardown
  strictly <= DRAIN_TIMEOUT (5s); regression test pins stalled cancel +
  stalled discard bound
- F53: success path now spawns session discard in the background off the
  critical path, preventing a slow or wedged discard from delaying result
  delivery to the initiator; safe because session keys are admission-scoped
- F54: executor module-level invariant doc updated to state the
  three-parameter session key (instance_id, delegation_id, admission)
- F55: truncation marker uses ASCII "..." instead of U+2026 ellipsis
- F51: documented MAX_INBOUND_FRAME_BYTES relation to CP-side configurable
  max_frame_bytes and future RegisterAck negotiation direction
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Round-8 Review Fixes (Head 944e03aa)

All Round-8 findings resolved:

  • F46 (Critical - Escape Inflation on Success Path): cap_text() and cap_result() now evaluate the JSON-escaped byte length via json_escaped_len() instead of raw UTF-8 byte length, ensuring control characters (which inflate up to 6x) and quotes/slashes (2x) cannot inflate a 512 KiB result past the CP pre-parse 1 MiB max_frame_bytes transport limit. Added regression tests verifying json_escaped_len() against serde_json and testing escape-heavy (\x1b) and quote-heavy payloads. Additionally, client::send() adds a transport-level outbound frame size guard.
  • F52 (Important - Shared Teardown Bound): cancel_and_discard() now shares a single 5s deadline (TEARDOWN_BOUND) across both the cancel and discard phases (discard uses the remaining budget or is skipped if cancel consumed the bound), keeping total teardown strictly within the client's DRAIN_TIMEOUT (5s). Added stalled_cancel_and_stalled_discard_share_one_teardown_bound regression test.
  • F53 (Important - Discard on Success Path): On clean turn completion, spawn_discard() now runs session discard in the background off the critical path, preventing a slow or wedged discard from delaying result delivery to the initiator. Safe because session keys are admission-scoped.
  • F54 (Important - Doc Residual): Updated module-level invariant doc in executor.rs to accurately reflect the three-parameter session key (instance_id, delegation_id, admission).
  • F55 (Important - ASCII Ellipsis): Replaced non-ASCII (U+2026) with ASCII ... in worker truncation marker.
  • F51 (Important - Frame Limit Documentation): Documented MAX_INBOUND_FRAME_BYTES relation to CP-side configurable max_frame_bytes and future RegisterAck negotiation direction.

@openab-app openab-app Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 24 hours. label Sep 3, 2026
@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Self-Review & CI Verification Summary (Head 13271cb3)

Follow-up update after clippy fix commit 13271cb3:

  • CI Status: All jobs passed green on Run 33816869090:
    • cargo check, cargo clippy, cargo clippy (unified): passed with -D warnings.
    • cargo test, all ACP test suites (gateway, mcp core, mcp pool, root): passed.
    • cargo build (unified): completed successfully.
  • Contract & Boundary Audit:
    • Transport safety: json_escaped_len + escape-aware cap_text verified safe against worst-case 6x control character and 2x quote/slash expansions under the 1 MiB frame ceiling; outbound transport-layer guard active.
    • Teardown bound: cancel_and_discard() strictly constrained within the shared 5s TEARDOWN_BOUND, eliminating overrun risks during connection drain.
    • Discard latency: Clean turn session discard deferred to background (spawn_discard), keeping initiator response latency minimal.
    • Lifecycle & Headless modes: Clean shutdown ordering verified (CP client unwinds delegations before pool teardown); headless worker boots smoothly without chat platform requirements.

PR is clean, fully converged with base feat/cp-observer, and ready for review.

@chaodu-agent

Copy link
Copy Markdown
Collaborator Author

Independent Deep Systems Review (Model: Gemini Pro Extended Thinking)

An adversarial systems-level code review of branch feat/cp-runtime-client (head 13271cb3) was performed focusing on concurrency, bounds enforcement, protocol fidelity, and teardown invariants.


1. High-Confidence Invariant Verifications (Passed)

  • JSON Escape & Frame Bound (F46):
    • json_escaped_len() and cap_text() correctly account for control character (up to 6x) and quote/slash (2x) expansions.
    • Multi-byte UTF-8 sequences are protected: c if (c as u32) < 0x20 strictly filters ASCII control characters; multi-byte continuation bytes ($\ge 0x80$) pass through intact.
    • Slicing via char_indices() guarantees valid UTF-8 boundaries.
    • Outbound transport guard in client.rs:send() prevents oversized frames (> 1 MiB) from ever hitting the wire.
  • SlotGuard RAII:
    • The drop guard ensures release() executes even if the serving task is cancelled or aborted mid-await during drain-window disconnects, preventing capacity leaks.
  • Race-Free Background Discard (F53):
    • Deferring session discard to spawn_discard() on clean turn completion is proven safe: session keys mix in (instance_id, delegation_id, admission). Immediate re-admission of the same delegation_id receives a new AdmissionToken, guaranteeing an isolated session key.
  • Graceful Shutdown Ordering:
    • main.rs shuts down the CP client (with a 10s deadline covering the 5s DRAIN_TIMEOUT) strictly before the session pool teardown, preventing dangling writes to dead sessions.

2. Architectural Observations & Follow-Up Recommendations

  1. Synchronous Disk I/O during Session Discard (SessionPool):

    • discard_session() in crates/openab-core/src/acp/pool.rs acquires the pool write lock and calls self.save_mapping() and self.save_meta(), which perform synchronous file writes (std::fs::write / rename).
    • Analysis: This mirrors the existing legacy pattern in reset_session(). While practically instantaneous on local SSDs, in Tokio async tasks synchronous disk I/O cannot be preempted by tokio::time::timeout.
    • Recommendation: Recommend addressing this in a future PR as part of a general SessionPool persistence refactoring (e.g. offloading to tokio::task::spawn_blocking or tokio::fs), keeping PR feat(cp): OAB runtime CP client — [control_plane] config, worker serving, headless mode (PR 3/4) #1471 focused and free of scope creep.
  2. Headless Mode Semantics ([mcp] + primary):

    • In src/main.rs:headless_run_mode(), type = "primary" alone fails fast (correct), but when combined with [mcp], it returns HeadlessMode::ControlPlaneWorker to run both the MCP facade and CP client registration in preparation for initiator capabilities.
    • Recommendation: Consider renaming the enum variant in a future cleanup to FullBoot or HeadlessClient to distinguish it from pure worker mode.
  3. Uniform Transport Guard (client.rs:register):

    • client.rs:register() calls sink.send(Message::Text(...)) directly rather than using the send() helper.
    • Analysis: Safe currently because RegisterParams is small (~hundreds of bytes), but standardizing on send(&mut sink, &frame) in the future maintains defense-in-depth across all outbound frame types.

Verdict

LGTM for PR #1471 scope. The Round 8 fixes (F46, F51-F55) are verified correct, CI is 100% green at 13271cb3, and identified observations are tracked for future subsystem improvements.

@chaodu-obk

chaodu-obk Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

CHANGES REQUESTED ⚠️ -- The round-8 transport and teardown fixes are correct, but the runtime client still has security, protocol-validation, failure-containment, and headless-contract gaps that should be fixed before merge.

What This PR Does

This stacked PR connects the OAB runtime to the Agent Control Plane. An optional [control_plane] section dials and registers the runtime, maintains heartbeats, serves delegated prompts through the local ACP session pool, and enables worker-only headless deployments.

How It Works

  • openab-cp splits wire types from its server feature so openab-core can consume the protocol without pulling the HTTP server into runtime images.
  • ControlPlaneClient owns dial, register, heartbeat, delegation, reconnect, and shutdown behavior.
  • DelegationExecutor performs local admission, gives each admission an isolated ACP session key, enforces deadline/capacity rules, and maps prompt outcomes to protocol results.
  • The latest commits make result truncation JSON-escape-aware, share one teardown deadline, defer successful session discard, and route cp/register through the outbound frame guard.

Findings

# Severity Finding Location
F1 🟡 ws:// is accepted without a guard, so the full bearer credential can cross a non-loopback network in cleartext config.rs:148, client.rs:202-209
F2 🟡 The DNS/TCP/TLS/WebSocket dial has no explicit timeout; a blackholed endpoint can stall reconnect for the OS timeout client.rs:219-222
F3 🟡 Reconnect backoff has no jitter, so a CP restart can synchronize the whole worker fleet into a thundering herd client.rs:163-170
F4 🟡 Every inflight lock uses expect; poisoning can cascade into panic-in-drop and abort the process during unwinding executor.rs:148-188,485-488
F5 🟡 handle_frame serves cp/delegate for both configured roles; a primary has no worker-side fail-closed guard client.rs:457-470
F6 🟡 Registration sends PROTOCOL_VERSION but never checks RegisterAck.protocol_version, silently accepting a mismatched hub client.rs:270-273
F7 🟡 ControlPlaneConfig derives Debug, exposing auth_key if the config is ever formatted in diagnostics config.rs:145-156
F8 🟡 HeadlessMode::ControlPlaneWorker also represents primary + [mcp]; the config table still says primary/no-adapter is always an error and omits that full-boot combination src/main.rs:208-232, docs/config-reference.md:814-819
F9 🟡 MAX_INBOUND_FRAME_BYTES is now a bidirectional transport ceiling, but its name and comment say it only closes the inbound direction client.rs:52-60,524-533
F10 🟡 The CP client task is observed only during shutdown; an early return or panic leaves a healthy-looking process that no longer registers or serves src/main.rs:963-973,1898-1910
F11 🟡 The no-adapter startup error omits valid [line], [lineworks], and [teams] adapters from its remediation guidance src/main.rs:509-511
F12 🟢 JSON-escape-aware result bounds, admission-scoped session keys/cancellation, shared teardown deadlines, background success discard, and real-server integration coverage are strong --
Finding Details

🟡 F1: Require an explicit secure transport policy

The credential is the complete agent identity and is inserted into the Authorization header before connect_async_with_config. Nothing rejects a non-loopback ws:// URL, and the config reference presents ws:// and wss:// as peers.

Requested change: reject non-loopback ws:// by default, or require an explicit allow_insecure_transport opt-in with a startup warning. Document the local-development exception.

🟡 F2 + F3: Make reconnect behavior fleet-safe

connect_async_with_config is not wrapped in a deadline, so a blackholed DNS/TCP/TLS path can remain stuck far beyond the intended 1/2/4/8/16/30-second retry schedule. Once the endpoint recovers, every replica uses the same deterministic delay.

Requested change: add a shutdown-aware connect timeout and full/equal jitter. Add paused-time tests for the timeout, ceiling, reset threshold, and jitter range.

🟡 F4: Do not let lock poisoning become process abort

active, admit, release, cancel, and cancel_all all call lock().expect("inflight mutex"). SlotGuard::drop calls release; if unwinding follows a panic that poisoned this lock, a second panic in Drop aborts the process. The map contains only admission 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. Add a poison-recovery regression test.

🟡 F5 + F6: Validate both sides of the protocol contract

The client registers its role and protocol version but then accepts every cp/delegate and every deserializable RegisterAck. This relies entirely on hub routing/version checks and removes runtime defense in depth.

Requested change: reject cp/delegate unless the configured role is Worker, and fail registration when the ack version differs from PROTOCOL_VERSION. Cover primary rejection and mismatched-ack tests.

🟡 F7: Redact the credential at the owning type

Header sensitivity protects only request formatting. The source config remains a normal String inside a derived Debug implementation, so one future structured diagnostic can expose it.

Requested change: implement redacted Debug for ControlPlaneConfig or wrap auth_key in a secret type whose Debug is always masked.

🟡 F8: Make the headless contract describe behavior

The code intentionally gives primary + [mcp] the full boot path, but returns a variant named ControlPlaneWorker; the operational table omits that combination and leaves its primary row unqualified.

Requested change: rename the variant to behavior-oriented FullBoot/ControlPlaneClient, add the primary + [mcp] table row, and qualify the startup-error row as primary without [mcp].

🟡 F9: Name the shared frame contract accurately

The latest outbound guard correctly uses the same 1 MiB ceiling as inbound WebSocket configuration, but MAX_INBOUND_FRAME_BYTES and its comment still claim outbound is already handled elsewhere and that this constant closes only the other direction.

Requested change: rename it to MAX_FRAME_BYTES or define explicit inbound/outbound limits, and document that executor caps payload fields while send() caps the complete serialized frame.

🟡 F10: Supervise the control-plane task

The handle is stored until shutdown. If run unexpectedly returns or panics during normal operation, the runtime can keep serving unrelated duties (or do nothing in headless mode) without a local health signal.

Requested change: supervise the handle and log/fail readiness on early exit; in worker-only mode, consider terminating so the deployment supervisor can recover it.

🟡 F11: Keep startup remediation complete

The startup condition recognizes LINE, LINE WORKS, and Teams through has_unified_platform, but the error lists only a subset of first-class adapters.

Requested change: include [line], [lineworks], and [teams], or replace the brittle enumeration with a reference to the platform configuration section.

Baseline Check
  • PR opened: 2026-08-12
  • Reviewed head: f533933f0791d467bacd9fead0ece3bb87b01f54
  • Declared stacked base: feat/cp-observer at 6485afcdee974bfa9c83a743d5ae33c6b0ea1774
  • Merge-base: 7473568d690ef5d544c2d11bbd81360dbe2ff6b8
  • Diff stat: 19 files, +3275/-37, reviewed locally against the declared base
  • Net-new value: runtime CP membership, delegated prompt execution, headless worker boot, and the server/protocol feature split
  • Exact-head CI: 40 successful check runs and 1 skipped; no failures
  • Local validation: source/diff review completed; local Rust tests could not be rerun because this environment has no cargo
What's Good (🟢)
  • Round-8 F46 and F52-F55 are fixed at the right layers and backed by adversarial or paused-time tests.
  • Admission identity is consistently threaded through session keys, cancellation, and terminal results.
  • The RAII slot guard preserves capacity across task aborts.
  • Seven integration tests exercise the real in-process CP server rather than a mock.
  • The feature split keeps server dependencies out of the runtime build and avoids Dockerfile churn.
  • The credential is absent from protocol frames, logs, and agent child environments; F1/F7 address the remaining transport/type-level exposure paths.

Addressing External Feedback

  • Author fix summaries: The claimed F46, F51-F55, clippy, frame-guard, teardown, discard-latency, and session-isolation fixes were independently re-read at the current head. F46 and F52-F55 are verified fixed. The latest f533933f commit also correctly routes cp/register through the complete-frame guard. F9 is the remaining naming/comment residual around that shared limit.
  • Independent systems summary: Its positive conclusions about escape-aware truncation, RAII slot release, admission-scoped discard safety, and shutdown ordering are accepted. Its register-guard observation is resolved by f533933f; the broader findings above remain outside that narrow verification scope.
  • Automated stale/close notices and reopen note: These were repository lifecycle events, not technical review concerns; the PR is open and active.
  • Inline review threads: None exist on the current head, so there was nothing to reply to or resolve.

Group Review Outcome

Lane Verdict Deduplicated contribution
Reviewer A CHANGES REQUESTED Headless naming/contract, frame-limit naming, duplicated role formatting
Reviewer B CHANGES REQUESTED Headless table and incomplete startup remediation
Reviewer C CHANGES REQUESTED Lock poisoning, jitter, connect timeout, insecure WebSocket transport
Coordinator CHANGES REQUESTED Role/version validation, credential redaction, task supervision, current-SHA lifecycle verification

5. Three Reasons We Might Not Need This PR

  1. The operational half lands before the initiator half. The fleet acquires long-lived sockets, reconnect behavior, and headless workers before PR 4/4 provides the primary-side user surface.
  2. The null chat adapter exposes an abstraction mismatch. Delegation is request/response RPC, but it is implemented through a chat-rendering seam whose future changes can leak into protocol results.
  3. A sidecar could isolate protocol churn. Keeping the CP client out of the main runtime would allow independent upgrades and failure containment, at the cost of another process and local interface.

The ADR already chose an in-process client, so these are architecture/timing tradeoffs rather than separate blockers. The requested changes above make that chosen model safer and easier to operate.

@chaodu-obk chaodu-obk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

CHANGES REQUESTED ⚠️ -- Security, protocol-validation, failure-containment, and headless-contract changes are still required.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/main.rs
// (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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/main.rs
runner,
std::time::Duration::from_secs(prompt_hard_timeout_secs),
));
tokio::spawn(client.run(shutdown_rx.clone()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/main.rs
}
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.)"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants