From 151b7690df37b98123aa4b97eec0f9a902b1793c Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sun, 15 Nov 2026 12:31:28 -0800 Subject: [PATCH] fix(aws): the auth bridge's poll no longer keeps a workspace up The auth bridge reads /proc/net every 2 s; on the agent that now goes through ContainerTarget::exec_oneshot_background, which sends X-Triple-C-Background: 1 so the agent does not count it as activity. Docker behaviour is unchanged. The idle-stop hint says what idle means now (R11) and what happens to unsaved work. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/auth_bridge/mod.rs | 3 +- app/src-tauri/src/host/agent.rs | 43 +++++++++++++++---- app/src-tauri/src/host/agent/tests.rs | 21 ++++++++- app/src-tauri/src/host/mod.rs | 17 ++++++++ .../projects/home/config/HostSection.tsx | 6 ++- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/app/src-tauri/src/auth_bridge/mod.rs b/app/src-tauri/src/auth_bridge/mod.rs index 43618af..9356bc4 100644 --- a/app/src-tauri/src/auth_bridge/mod.rs +++ b/app/src-tauri/src/auth_bridge/mod.rs @@ -326,7 +326,8 @@ async fn poll_loop( // bridge or stopping the container doesn't wait out an in-flight poll. let discovery = tokio::select! { _ = cancel.changed() => break, - res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => { + // Background: on AWS this poll must not keep an idle workspace up. + res = container.exec_oneshot_background("claude", cmd, PROC_NET_OUTPUT_LIMIT) => { res.map(|(out, _)| out) } }; diff --git a/app/src-tauri/src/host/agent.rs b/app/src-tauri/src/host/agent.rs index a8f7001..011838e 100644 --- a/app/src-tauri/src/host/agent.rs +++ b/app/src-tauri/src/host/agent.rs @@ -277,20 +277,35 @@ async fn exec_oneshot_raw( cmd: Vec, env: Vec, limit: usize, +) -> Result { + exec_oneshot_raw_marked(ep, user, cmd, env, limit, false).await +} + +/// The agent's background marker (protocol spec, "Activity"): a request that +/// carries it never counts as activity for idle stop. +pub const BACKGROUND_HEADER: &str = "X-Triple-C-Background"; + +async fn exec_oneshot_raw_marked( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + env: Vec, + limit: usize, + background: bool, ) -> Result { const CONTEXT: &str = "Failed to start exec"; let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false); let mut body = spec_json(&spec); body.insert("limit".into(), serde_json::json!(limit)); - let resp = send( - CONTEXT, - http() - .post(ep.url("v1/exec")) - .header(reqwest::header::AUTHORIZATION, ep.auth()) - .json(&body), - ) - .await?; + let mut req = http() + .post(ep.url("v1/exec")) + .header(reqwest::header::AUTHORIZATION, ep.auth()) + .json(&body); + if background { + req = req.header(BACKGROUND_HEADER, "1"); + } + let resp = send(CONTEXT, req).await?; let status = resp.status(); if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE { @@ -352,6 +367,18 @@ pub async fn exec_oneshot_limited( Ok((out.text(), out.exit_code)) } +/// [`exec_oneshot_limited`] marked as a background poll, so it never keeps +/// an idle workspace up (the auth bridge's `/proc/net` reads). +pub async fn exec_oneshot_background( + ep: &AgentEndpoint, + user: &str, + cmd: Vec, + limit: usize, +) -> Result<(String, i64), String> { + let out = exec_oneshot_raw_marked(ep, user, cmd, Vec::new(), limit, true).await?; + Ok((out.text(), out.exit_code)) +} + /// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`]. pub async fn exec_oneshot_as( ep: &AgentEndpoint, diff --git a/app/src-tauri/src/host/agent/tests.rs b/app/src-tauri/src/host/agent/tests.rs index 067a52d..c0ce2f0 100644 --- a/app/src-tauri/src/host/agent/tests.rs +++ b/app/src-tauri/src/host/agent/tests.rs @@ -22,6 +22,8 @@ const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000"; struct Seen { auth: Vec, exec_bodies: Vec, + /// The background marker of each `/v1/exec`, if any. + exec_background: Vec>, detached_bodies: Vec, status_polls: u32, resizes: Vec<(String, Value)>, @@ -62,7 +64,13 @@ async fn exec(State(f): St, h: HeaderMap, Json(body): Json) -> Response { if !f.authed(&h) { return unauthorized(); } - f.seen.lock().unwrap().exec_bodies.push(body.clone()); + { + let mut seen = f.seen.lock().unwrap(); + seen.exec_bodies.push(body.clone()); + seen.exec_background.push( + h.get("x-triple-c-background").and_then(|v| v.to_str().ok()).map(str::to_string), + ); + } match body["cmd"][0].as_str().unwrap_or("") { "sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0})) .into_response(), @@ -304,6 +312,17 @@ async fn other_errors_carry_the_agents_message_and_no_token() { assert_eq!(e, "Failed to start exec: boom"); } +#[tokio::test] +async fn only_a_background_poll_carries_the_background_marker() { + let (ep, fake) = fake_agent().await; + let t = target(&ep); + t.exec_oneshot_limited("claude", cmd("sh"), 1000).await.unwrap(); + t.exec_oneshot_background("claude", cmd("sh"), 1000).await.unwrap(); + let seen = fake.seen.lock().unwrap(); + assert_eq!(seen.exec_background, [None, Some("1".to_string())]); + assert_eq!(seen.exec_bodies[0], seen.exec_bodies[1], "the request is otherwise the same"); +} + // ── one-shot execs ─────────────────────────────────────────────────────────── #[tokio::test] diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index b149f02..f4a3522 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -270,6 +270,23 @@ impl ContainerTarget { } } + /// [`Self::exec_oneshot_limited`] for a background poll: on the agent it + /// carries the background marker, so it never counts as activity for + /// idle stop. Docker has no idle stop; the call is the same there. + pub async fn exec_oneshot_background( + &self, + user: &str, + cmd: Vec, + limit: usize, + ) -> Result<(String, i64), String> { + match self { + ContainerTarget::Docker { container_id } => { + docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await + } + ContainerTarget::Agent(ep) => agent::exec_oneshot_background(ep, user, cmd, limit).await, + } + } + /// stdout and stderr kept apart: `(stdout, stderr, exit_code)`. pub async fn exec_oneshot_streams_as( &self, diff --git a/app/src/components/projects/home/config/HostSection.tsx b/app/src/components/projects/home/config/HostSection.tsx index d11b603..348ea29 100644 --- a/app/src/components/projects/home/config/HostSection.tsx +++ b/app/src/components/projects/home/config/HostSection.tsx @@ -257,7 +257,11 @@ export default function HostSection({ project, save, disabled }: Props) {