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 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-11-15 12:31:28 -08:00
1 parent 95b68ed068
commit 151b7690df
5 files changed
+79 -11

No files matched your search

+2 -1
View File
@@ -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)
}
};
+35 -8
View File
@@ -277,20 +277,35 @@ async fn exec_oneshot_raw(
cmd: Vec<String>,
env: Vec<String>,
limit: usize,
) -> Result<OneshotOutput, String> {
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<String>,
env: Vec<String>,
limit: usize,
background: bool,
) -> Result<OneshotOutput, String> {
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<String>,
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,
+20 -1
View File
@@ -22,6 +22,8 @@ const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000";
struct Seen {
auth: Vec<String>,
exec_bodies: Vec<Value>,
/// The background marker of each `/v1/exec`, if any.
exec_background: Vec<Option<String>>,
detached_bodies: Vec<Value>,
status_polls: u32,
resizes: Vec<(String, Value)>,
@@ -62,7 +64,13 @@ async fn exec(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> 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]
+17
View File
@@ -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<String>,
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,
@@ -257,7 +257,11 @@ export default function HostSection({ project, save, disabled }: Props) {
<SwitchRow
label="Stop when idle"
hint="Stops the workspace after this many minutes with no terminal activity, no running Claude and no scheduled job."
hint={
aws.wip_push_on_stop
? "Stops the workspace after this many minutes without terminal input or output, Claude running or a scheduled job. Unsaved git work is pushed first; if that push fails, it stays up."
: "Stops the workspace after this many minutes without terminal input or output, Claude running or a scheduled job. With the work-in-progress push off, unsaved git work keeps it running."
}
control={
<Toggle
label="Stop when idle"