Answer a `devin` request instead of dropping it on the floor

7b7453bd1006 · AtlantisPleb · · parent 322e5920aee7

Answer a `devin` request instead of dropping it on the floor

`OpenAgentsWeb.ComputerChannel` pushes a delegation request under the name of
its kind — `handle_info({:computer_request, kind, request_id, payload, from})`
accepts `kind in [:run, :devin, :agent]` and does `push(socket,
Atom.to_string(kind), …)` — then tracks the caller until a terminal `exit` or
`refused` comes back on that `request_id`.

The frame match handled `run`, `probe`, `agent`, and `cancel`. `devin` fell
into the catch-all arm and was discarded: no frame, no journal line, and a
server left waiting on a request the controller had already thrown away, until
whatever timeout the caller has. The `agent` arm's own comment says why that is
the wrong answer — "pretending to accept it would leave the server waiting for
output that never comes" — and the same reasoning had simply not been applied
to the kind next to it.

`devin` now joins `agent`: refused as `unsupported`, journaled, answered on its
own `request_id`. Neither subsystem is ported, and neither pretends to be.

The test walks the kinds the channel can push rather than asserting the one
that happened to work. Without this change it fails with "the client never sent
a refused frame" after waiting out the full frame deadline, which is exactly
what the server would have done.

`cargo test -p openagents-cli`: 273 passed, 0 failed.

Refs #79

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/computer.rs
  • modified crates/openagents-cli/tests/computer_api_test.rs

Diff

2 files changed, +97 -6

crates/openagents-cli/src/computer.rs modified +15 -5

@@ -2290,12 +2290,22 @@ fn serve_connection(

2290 2290
                    &cancellations,
2291 2291
                );
2292 2292
            }
2293
            "agent" => {
2294
                // ACP delegation is a separate subsystem this build does not
2295
                // carry. Saying so is the honest answer; pretending to accept it
2296
                // would leave the server waiting for output that never comes.
2293
            // ACP delegation is a separate subsystem this build does not carry,
2294
            // and `devin` is a second delegation kind it does not carry either.
2295
            // Saying so is the honest answer; pretending to accept either would
2296
            // leave the server waiting for output that never comes.
2297
            //
2298
            // `OpenAgentsWeb.ComputerChannel` pushes a request by the name of
2299
            // its kind — `handle_info({:computer_request, kind, …})` for `kind
2300
            // in [:run, :devin, :agent]` does `push(socket,
2301
            // Atom.to_string(kind), …)` — so every one of those names arrives
2302
            // here as an event carrying a `request_id` the server is tracking.
2303
            // `devin` used to fall through to the catch-all below and be
2304
            // dropped without a frame or a journal line, which is the exact
2305
            // failure this arm was written to prevent, one kind over.
2306
            "agent" | "devin" => {
2297 2307
                let request = CommandRequest {
2298
                    argv: vec!["<agent>".to_string()],
2308
                    argv: vec![format!("<{event}>")],
2299 2309
                    cwd: String::new(),
2300 2310
                };
2301 2311
                let _ = journal.append(
crates/openagents-cli/tests/computer_api_test.rs modified +82 -1

@@ -570,6 +570,20 @@ struct StubController {

570 570
}
571 571
572 572
fn start_stub_controller(machine_id: &str, run_payload: serde_json::Value) -> StubController {
573
    start_stub_controller_pushing(machine_id, "run", run_payload)
574
}
575
576
/// The same peer, pushing a request of a named kind.
577
///
578
/// `OpenAgentsWeb.ComputerChannel` pushes a request under the name of its kind
579
/// — `push(socket, Atom.to_string(kind), …)` for `kind in [:run, :devin,
580
/// :agent]` — so `run` is one of three event names that can arrive, and the
581
/// stub needs to be able to send the other two.
582
fn start_stub_controller_pushing(
583
    machine_id: &str,
584
    event: &'static str,
585
    run_payload: serde_json::Value,
586
) -> StubController {
573 587
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
574 588
    let port = listener.local_addr().unwrap().port();
575 589
    let (sender, frames) = channel();

@@ -589,7 +603,7 @@ fn start_stub_controller(machine_id: &str, run_payload: serde_json::Value) -> St

589 603
        let _ = socket.send(tungstenite::Message::Text(reply.to_string().into()));
590 604
        // hello
591 605
        let _ = socket.read();
592
        let ask = serde_json::json!([serde_json::Value::Null, "9", topic, "run", run_payload]);
606
        let ask = serde_json::json!([serde_json::Value::Null, "9", topic, event, run_payload]);
593 607
        let _ = socket.send(tungstenite::Message::Text(ask.to_string().into()));
594 608
595 609
        let deadline = std::time::Instant::now() + Duration::from_secs(20);

@@ -708,6 +722,73 @@ fn test_up_refuses_a_command_outside_the_allowlist_and_journals_it() {

708 722
    );
709 723
}
710 724
725
/// Every delegation kind the channel can push gets an answer.
726
///
727
/// `OpenAgentsWeb.ComputerChannel` pushes a request under the name of its kind
728
/// and then waits for a terminal frame carrying that `request_id`:
729
/// `handle_info({:computer_request, kind, request_id, payload, from})` accepts
730
/// `kind in [:run, :devin, :agent]` and tracks the caller until an `exit` or a
731
/// `refused` comes back. This build carries neither ACP delegation nor Devin,
732
/// so both must answer `refused`.
733
///
734
/// `agent` did. `devin` fell through the frame match's catch-all arm and was
735
/// dropped: no frame, no journal line, and a server left waiting on a request
736
/// the controller had already thrown away. A silent drop is the one answer a
737
/// request kind must never get, which is why this walks the kinds rather than
738
/// asserting the one that happened to be handled.
739
#[test]
740
fn test_up_refuses_every_delegation_kind_it_cannot_serve() {
741
    for (index, event) in ["agent", "devin"].into_iter().enumerate() {
742
        let directory = tempfile::tempdir().unwrap();
743
        let root = directory.path().join("checkout");
744
        std::fs::create_dir_all(&root).unwrap();
745
        let config = config_at(directory.path(), Tier::Curated, vec![root.clone()]);
746
        let journal = Journal::at(directory.path().join("journal.ndjson"));
747
        let machine = format!("machine-delegation-{index}");
748
        let request_id = format!("req-{event}");
749
750
        let stub = start_stub_controller_pushing(
751
            &machine,
752
            event,
753
            serde_json::json!({
754
                "request_id": request_id,
755
                "prompt": "do the thing",
756
                "cwd": root.display().to_string(),
757
            }),
758
        );
759
760
        serve(
761
            &stub.origin,
762
            &openagents_cli::auth::Secret::new("smct_stub"),
763
            &machine,
764
            &serde_json::json!({"agent_version": "test"}),
765
            &config,
766
            &journal,
767
            |_| {},
768
        );
769
770
        let refused = next_frame(&stub.frames, "refused");
771
        assert_eq!(
772
            refused.get("request_id").and_then(|v| v.as_str()),
773
            Some(request_id.as_str()),
774
            "a `{event}` request must be answered on its own request_id: {refused}"
775
        );
776
        assert_eq!(
777
            refused.get("reason").and_then(|v| v.as_str()),
778
            Some("unsupported"),
779
            "a `{event}` request this build cannot serve must say so: {refused}"
780
        );
781
782
        let entries = journal.read(50).unwrap();
783
        assert!(
784
            entries
785
                .iter()
786
                .any(|entry| entry.request_id == request_id && entry.outcome == "refused"),
787
            "the `{event}` refusal must reach the local journal too"
788
        );
789
    }
790
}
791
711 792
/// An allowed command runs, streams its real output, and reports a real exit.
712 793
#[test]
713 794
fn test_up_serves_a_bounded_allowed_request() {

This page updates live while a promote is in flight · changelog