Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: every ACP run gets one shared, empty cwd

opencode writes the session's cwd into the system message's environment
block ("Working directory: …", "Workspace root folder: …"), ahead of the
PR's user message. Each ACP classification ran with its per-call scratch
directory, /tmp/review-classifier-<pid>-<counter>, as cwd, so every call
sent a different system message and the provider's prompt cache could not
match past it.

The session's cwd is now one fixed directory, <tmp>/review-classifier-acp-cwd,
shared by every run. HOME (the config, system.md and opencode's own state)
stays a directory per run, removed after it, as before. With no tool and
no project config nothing writes into the cwd: a live opencode 1.15.10 run
through initialize, session/new and a prompt left it empty, with its state
all under HOME. So concurrent runs can share it, and no state carries from
one run to the next.

Refs #5245
This commit is contained in:
atlas 2026-10-14 14:06:55 +02:00 • committed by mara
commit 2fb6b39f9f
4 changed files with 70 additions and 11 deletions

View file

@ -1,6 +1,8 @@
//! One classification on the ACP runtime: `opencode acp`, driven through
//! [`hive_runtime::AcpRuntime`] for one prompt on a new session, in a
//! directory made for it and removed after it.
//! [`hive_runtime::AcpRuntime`] for one prompt on a new session. Its config,
//! system prompt and opencode's state live in a directory made for it and
//! removed after it, its `HOME`; its working directory is [`Acp::cwd`],
//! shared by every run.
//!
//! The agent has no tool at all. opencode runs some tools, `read` among them,
//! without asking, so a deny-all permission policy alone would never see them:
@ -68,6 +70,11 @@ pub struct Acp<S> {
/// [`CANCEL_WAIT`] outside the tests.
pub cancel_wait: Duration,
pub cost: S,
/// The empty working directory every run's session gets. opencode writes
/// it into the system message, so one path for every run keeps that
/// message the same and the provider's prompt cache usable; with no tool
/// and no project config, nothing writes into it.
pub cwd: PathBuf,
}
impl<S: CostSink> crate::run::Model for Acp<S> {
@ -97,6 +104,7 @@ impl<S: CostSink> Acp<S> {
let config_file = dir.join("opencode.json");
tokio::fs::create_dir_all(dir)
.await
.and(tokio::fs::create_dir_all(&self.cwd).await)
.and(tokio::fs::write(&system, crate::prompt::SYSTEM).await)
.and(tokio::fs::write(&config_file, opencode_config(provider).to_string()).await)
.map_err(|e| format!("preparing the run's directory: {e}"))?;
@ -112,7 +120,7 @@ impl<S: CostSink> Acp<S> {
.ok_or("the ACP runtime offers no cancel handle")?;
let config = Config {
effort: run.effort.map(|e| e.as_str().to_owned()),
cwd: Some(dir.to_owned()),
cwd: Some(self.cwd.clone()),
idle_timeout: Some(IDLE_TIMEOUT),
..Config::default()
};
@ -271,8 +279,10 @@ mod tests {
/// An ACP agent in plain `sh`, logging to `<log>.*`. It writes its
/// environment and copies of its config and the system prompt file
/// beside it at start, appends each method it is sent to
/// `<log>.methods` and each prompt request to `<log>.prompts`, and answers a prompt with a text chunk
/// beside it and appends its `$HOME` to `<log>.homes` at start, appends
/// each method it is sent to `<log>.methods`, each `session/new` request
/// to `<log>.sessions` and each prompt request to `<log>.prompts`, and
/// answers a prompt with a text chunk
/// holding a verdict, then `end_turn` with 1000 input and 200 output
/// tokens. A prompt holding `ASK-READ` first asks permission for a
/// `read` and appends the reply to `<log>.permission`; one holding
@ -283,6 +293,7 @@ log="$1"
env > "$log.env"
cp "$OPENCODE_CONFIG" "$log.config"
cp "${OPENCODE_CONFIG%/*}/system.md" "$log.system"
printf '%s\n' "$HOME" >> "$log.homes"
stalled=
while IFS= read -r line; do
id=${line#*\"id\":}; id=${id%%[,\}]*}
@ -292,6 +303,7 @@ while IFS= read -r line; do
initialize)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{"mcpCapabilities":{"http":true}}}}\n' "$id" ;;
session/new)
printf '%s\n' "$line" >> "$log.sessions"
printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1"}}\n' "$id" ;;
session/cancel)
[ -n "$stalled" ] && printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"cancelled"}}\n' "$stalled" ;;
@ -338,6 +350,7 @@ done
deadline,
cancel_wait: Duration::from_millis(500),
cost: Arc::clone(&costs),
cwd: dir.join("cwd"),
};
(acp, costs)
}
@ -507,4 +520,45 @@ done
assert!(acp.ask("classify", "p", &claude).await.is_err());
assert_eq!(read(dir.path(), "methods"), "", "no agent started");
}
/// The cwd of each session the agent was asked for, in order.
fn cwds(dir: &Path) -> Vec<String> {
read(dir, "sessions")
.lines()
.map(|l| {
let request: Value = serde_json::from_str(l).expect("JSON");
request["params"]["cwd"].as_str().expect("a cwd").to_owned()
})
.collect()
}
#[tokio::test]
async fn every_run_gets_the_same_cwd_even_at_once() {
let dir = tempfile::tempdir().expect("a dir");
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
let (run_one, run_two) = (run(), run());
let (one, two) = tokio::join!(acp.ask("one", "p", &run_one), acp.ask("two", "p", &run_two));
one.expect("an answer");
two.expect("an answer");
acp.ask("three", "p", &run()).await.expect("an answer");
let cwd = dir.path().join("cwd").display().to_string();
assert_eq!(cwds(dir.path()), [cwd.clone(), cwd.clone(), cwd]);
}
#[tokio::test]
async fn each_run_has_a_home_of_its_own_removed_after_it() {
let dir = tempfile::tempdir().expect("a dir");
let (acp, _) = acp(dir.path(), Duration::from_mins(1));
acp.ask("one", "p", &run()).await.expect("an answer");
acp.ask("two", "p", &run()).await.expect("an answer");
let homes = read(dir.path(), "homes");
let homes: Vec<&str> = homes.lines().collect();
assert_eq!(homes.len(), 2, "{homes:?}");
assert_ne!(homes[0], homes[1]);
for home in homes {
assert!(!Path::new(home).exists(), "{home}");
}
}
}

View file

@ -70,6 +70,7 @@ async fn serve() -> Result<()> {
deadline: swarm_queue_client::review_classifier::CLASSIFICATION_DEADLINE,
cancel_wait: acp::CANCEL_WAIT,
cost: cost::Otel,
cwd: std::env::temp_dir().join("review-classifier-acp-cwd"),
},
};
let secret = client_secret_with_retry(&client_id).await?;

View file

@ -3,11 +3,11 @@
//!
//! On the API key runtime each classification is one `claude --print` with
//! no session, no tools and no MCP server; on the ACP runtime it is one
//! prompt on a new ACP session ([`crate::acp`]). Either way it runs in a
//! directory made for it and removed after it, so nothing carries from one
//! run to the next. The key is read from the store for every run and reaches
//! only the child's environment, which keeps nothing of the daemon's beyond
//! [`crate::child_env`]'s allowlist.
//! prompt on a new ACP session ([`crate::acp`]). Either way its files live
//! in a directory made for it and removed after it, so nothing carries from
//! one run to the next. The key is read from the store for every run and
//! reaches only the child's environment, which keeps nothing of the daemon's
//! beyond [`crate::child_env`]'s allowlist.
use std::future::Future;
use std::path::{Path, PathBuf};