Skip to content

A controller's response shape depends on whether its handler happened to log #6080

Description

@M3gA-Mind

Summary

A controller's response shape depends on whether its handler happened to log

Found during the e2e coverage wave and independently verified by a second engineer who did not
write the original report — verification was cross-assigned so nobody checked their own finding.

  • Namespace / method: cross-cutting — RpcOutcome::into_cli_compatible_json,
    the shared return path for most controllers. Observed on
    openhuman.javascript_list_tools / openhuman.javascript_execute_tool vs
    openhuman.skills_read_resource / openhuman.skills_recent_runs.
  • Where: src/rpc/mod.rs:54-62
  • Verified at: openhuman main @ d1ed68f9b

Problem

Expected

one namespace, one response shape. A caller writes one parser.

Actual

pub fn into_cli_compatible_json(self) -> Result<Value, String> {
    let RpcOutcome { value, logs } = self;
    let value = serde_json::to_value(value)?;
    if logs.is_empty() {
        Ok(value)                                  // bare
    } else {
        Ok(json!({ "result": value, "logs": logs })) // wrapped
    }
}

The envelope is decided by the log vector, not by the method. So:

method logs response
javascript_list_tools always 1 line {result: {tools: […]}, logs: […]}
skills_read_resource Vec::new() {workflow_id, content, bytes, …} bare
skills_recent_runs Vec::new() {runs: […]} bare

Both spellings live in the same skills/javascript family and neither is
documented in the controller catalog — the ControllerSchema outputs describe
the inner value in both cases, so the schema is silently right for one and
silently wrong for the other.

The failure mode is nasty because it is conditional: a handler that gains a
tracing-adjacent log line later changes its own wire shape without any
schema change, and a caller that worked yesterday reads undefined today.
Nothing in CI would catch it.

Steps to reproduce

tests/raw_coverage/skill_runtime_e2e.rs
javascript_bridge_lists_and_executes_a_real_tool, which asserts the wrapped
form explicitly before unwrapping, so the day this is normalised the suite
fails loudly. Found the hard way: the first run of that case died on
no 'tools' array: {"logs":["javascript.list_tools: count=227"],"result":{"tools":[…]}}.

Independent verification (CONFIRMED — and the blast radius is materially larger than the report says.)

Cited location still accurate: yes, exactly. src/rpc/mod.rs:54-62, quoted
verbatim and unchanged.

Repro run: cargo test --test raw_coverage_all --features "$(bash scripts/ci/product-features.sh)" -- W5VERIFY_
plus grep -rln "'result' in " app/src/ | xargs grep -ln logs

Observed

test W5VERIFY_skill_runtime_e2e::javascript_bridge_lists_and_executes_a_real_tool ... ok

Per-method construction confirmed at source:

// src/openhuman/runtime/node/rpc.rs:26-33  → non-empty log → WRAPPED
let log = vec![format!("javascript.list_tools: count={}",)];
RpcOutcome::new(payload, log).into_cli_compatible_json()

// src/openhuman/skills/schemas/handlers.rs:147-150 → Vec::new() → BARE
to_json(RpcOutcome::new(WorkflowsRecentRunsResult { runs }, Vec::new()))
// src/openhuman/skills/schemas/handlers.rs:203-211 → Vec::new() → BARE

Diagnosis assessment: correct, and I hit this independently while writing the W4
suites — billing_get_balance (RpcOutcome::single_log) comes back wrapped while
cost_get_dashboard (RpcOutcome::new(_, Vec::new())) comes back bare, from two
handlers a caller would reasonably treat as one family. My w4_shared::peel() helper
exists solely to absorb this, which is corroboration from a second worker who did not
know of this report.

Is the expected behaviour correct: yes — "one namespace, one response shape" is
the right expectation, and the report is right that fixing it is a breaking change
needing a migration rather than a quiet normalisation.

Solution

as the report says — make the envelope
unconditional, or bare with logs on a side channel. Whichever is chosen, the 13 files
below are the migration surface. Interim mitigation with no breaking change: have the
controller catalog record which shape each controller returns, since that is
derivable from whether the handler passes a non-empty log vector.

Blast radius:

Acceptance criteria

  • Repro gone — the reproduction above no longer produces the reported behaviour.
  • Regression safety — coverage added or updated so this cannot come back silently.
  • Diff coverage ≥ 80% — the fix PR meets the changed-lines coverage gate.
  • Blast radius checked — every consumer named above still behaves correctly.

Related

Filed from the e2e coverage wave (#6053, #6054, #6055, #6058, #6059, #6060), which covered 244
previously untested RPC controllers and surfaced 33 defects along the way.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    priority: p2Soon. Real but survivable — a rough edge, a gap, a thing that will bite later.

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions