Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions integrations/opencode/marmot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ tracing-subscriber.workspace = true

[dev-dependencies]
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
3 changes: 2 additions & 1 deletion integrations/opencode/marmot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@ Configure with environment variables:
| `WN_OPENCODE_ADMIN_HEX` | unset | Legacy alias for `WN_OPENCODE_ALLOWED_SENDERS_HEX` |
| `WN_OPENCODE_ACCOUNT_ID_HEX` | first local account | Specific `wn-agent` account to use |
| `WN_OPENCODE_BIN` | `opencode` | OpenCode binary or executable path |
| `WN_OPENCODE_TIMEOUT_SECS` | `300` | Hard timeout for each OpenCode invocation |
| `WN_OPENCODE_IDLE_TIMEOUT_SECS` | `120` | Kill the invocation when OpenCode produces no stdout line for this long |
| `WN_OPENCODE_TIMEOUT_SECS` | `3600` | Generous total safety cap for wedge recovery; ongoing output resets only the idle timer |
| `WN_OPENCODE_REQUEST_TIMEOUT_SECS` | `30` | Timeout for each control-socket request |
| `WN_OPENCODE_MAX_REPLY_BYTES` | `30000` | UTF-8 byte limit for each durable Marmot reply chunk |
| `WN_OPENCODE_MAX_PENDING_PER_GROUP` | `4` | Per-group in-flight/queued prompt cap |
Expand Down
161 changes: 144 additions & 17 deletions integrations/opencode/marmot/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use crate::chunking::split_reply_chunks;
use crate::config::{Config, dirs_home};
use crate::control::ControlClient;
use crate::error::{HarnessError, Result};
use crate::opencode::{Invocation, Outcome, RunnerEvent};
use crate::opencode::{Invocation, Outcome, RunFailure, RunnerEvent};
use crate::repo_picker::{parse_repo_picker, resolve_repo, validate_session_cwd};
use crate::store::{SessionRecord, SessionStore};

Expand Down Expand Up @@ -343,6 +343,7 @@ async fn handle_message(ctx: Arc<BridgeContext>, inbound: InboundPrompt, permit:
let invocation = Invocation {
bin: ctx.cfg.opencode_bin.clone(),
timeout: ctx.cfg.opencode_timeout,
idle_timeout: ctx.cfg.opencode_idle_timeout,
cwd: cwd.clone(),
session_id,
prompt,
Expand Down Expand Up @@ -402,28 +403,28 @@ async fn handle_message(ctx: Arc<BridgeContext>, inbound: InboundPrompt, permit:
)
.await;
}
Ok(Err(err)) => {
Ok(Err(failure)) => {
warn!(
target: TRACE_TARGET,
method = "opencode_run",
error_kind = err.privacy_safe_kind(),
error_kind = failure.error.privacy_safe_kind(),
"opencode invocation failed"
);
let text = match err {
HarnessError::OpencodeTimedOut => {
"[wn-opencode] opencode timed out before producing a complete response."
}
HarnessError::OpencodeSpawn => {
"[wn-opencode] failed to start opencode; check WN_OPENCODE_BIN."
}
_ => "[wn-opencode] opencode failed while streaming its response.",
};
let text = handle_opencode_run_failure(
&ctx.sessions,
&inbound.group_ref,
known_session.as_ref(),
cwd,
&failure,
ctx.cfg.opencode_idle_timeout,
)
.await;
let _ = send_reply(
&ctx,
&inbound.account_ref,
&inbound.group_ref,
&inbound.message_ref,
text,
&text,
0,
)
.await;
Expand Down Expand Up @@ -475,10 +476,14 @@ async fn finish_success(
if !delivery.failed
&& needs_persist
&& let Some(session_id) = outcome.observed_session
&& let Err(err) = ctx
.sessions
.set(&inbound.group_ref, SessionRecord { session_id, cwd })
.await
&& let Err(err) = persist_observed_session_if_unset(
&ctx.sessions,
&inbound.group_ref,
known_session.as_ref(),
cwd,
Some(session_id),
)
.await
{
warn!(
target: TRACE_TARGET,
Expand Down Expand Up @@ -527,6 +532,64 @@ struct DeliveryReport {
failure_chunk_index: usize,
}

async fn handle_opencode_run_failure(
sessions: &SessionStore,
group_ref: &str,
known_session: Option<&SessionRecord>,
cwd: PathBuf,
failure: &RunFailure,
idle_timeout: Duration,
) -> String {
if let Err(store_err) = persist_observed_session_if_unset(
sessions,
group_ref,
known_session,
cwd,
failure.observed_session.clone(),
)
.await
{
warn!(
target: TRACE_TARGET,
method = "session_store",
error_kind = store_err.privacy_safe_kind(),
"failed to persist opencode session"
);
}

match &failure.error {
HarnessError::OpencodeIdle => format!(
"[wn-opencode] opencode went silent for {}s without producing output; killing the invocation.",
idle_timeout.as_secs()
),
HarnessError::OpencodeTimedOut => {
"[wn-opencode] opencode timed out before producing a complete response.".to_owned()
}
HarnessError::OpencodeSpawn => {
"[wn-opencode] failed to start opencode; check WN_OPENCODE_BIN.".to_owned()
}
_ => "[wn-opencode] opencode failed while streaming its response.".to_owned(),
}
}

async fn persist_observed_session_if_unset(
sessions: &SessionStore,
group_ref: &str,
known_session: Option<&SessionRecord>,
cwd: PathBuf,
observed_session: Option<String>,
) -> Result<()> {
let needs_persist = known_session
.as_ref()
.is_none_or(|record| record.session_id.is_empty());
if needs_persist && let Some(session_id) = observed_session {
sessions
.set(group_ref, SessionRecord { session_id, cwd })
.await?;
}
Ok(())
}

async fn resolve_cwd_and_prompt(
ctx: &BridgeContext,
inbound: &InboundPrompt,
Expand Down Expand Up @@ -753,6 +816,7 @@ impl InboundDedupe {
#[cfg(test)]
mod tests {
use super::*;
use crate::store::SessionStore;

#[tokio::test]
async fn dedupe_rejects_repeated_message_refs() {
Expand Down Expand Up @@ -784,4 +848,67 @@ mod tests {
Some("AA".repeat(32))
);
}

#[tokio::test]
async fn persist_observed_session_only_when_unset() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sessions.json");
let home = dir.path().to_path_buf();
let store = SessionStore::load(path.clone(), &home).unwrap();
let cwd = home.join("proj");

persist_observed_session_if_unset(
&store,
"group1",
None,
cwd.clone(),
Some("ses_new".to_owned()),
)
.await
.unwrap();
let record = store.get("group1").await.unwrap();
assert_eq!(record.session_id, "ses_new");
assert_eq!(record.cwd, cwd);

persist_observed_session_if_unset(
&store,
"group1",
Some(&record),
cwd.clone(),
Some("ses_other".to_owned()),
)
.await
.unwrap();
let record = store.get("group1").await.unwrap();
assert_eq!(record.session_id, "ses_new");
}

#[tokio::test]
async fn run_failure_path_persists_first_session_and_selects_idle_reply() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().to_path_buf();
let store = SessionStore::load(home.join("sessions.json"), &home).unwrap();
let failure = RunFailure {
error: HarnessError::OpencodeIdle,
observed_session: Some("ses_idle".to_owned()),
};

let reply = handle_opencode_run_failure(
&store,
"group1",
None,
home.clone(),
&failure,
Duration::from_secs(45),
)
.await;

let record = store.get("group1").await.unwrap();
assert_eq!(record.session_id, "ses_idle");
assert_eq!(record.cwd, home);
assert_eq!(
reply,
"[wn-opencode] opencode went silent for 45s without producing output; killing the invocation."
);
}
}
29 changes: 28 additions & 1 deletion integrations/opencode/marmot/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ use crate::error::{HarnessError, Result};

pub(crate) const DEFAULT_MAX_REPLY_BYTES: usize = 30_000;
pub(crate) const MARMOT_MESSAGE_BYTES_CEILING: usize = 60_000;
const DEFAULT_OPENCODE_TIMEOUT_SECS: u64 = 300;
const DEFAULT_OPENCODE_TIMEOUT_SECS: u64 = 3600;
const DEFAULT_OPENCODE_IDLE_TIMEOUT_SECS: u64 = 120;
const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
const DEFAULT_MAX_PENDING_PER_GROUP: usize = 4;
const MIN_REPLY_BYTES: usize = 4;
Expand All @@ -21,6 +22,7 @@ pub(crate) struct Config {
pub(crate) account_id_hex: Option<String>,
pub(crate) opencode_bin: String,
pub(crate) opencode_timeout: Duration,
pub(crate) opencode_idle_timeout: Duration,
pub(crate) request_timeout: Duration,
pub(crate) max_reply_bytes: usize,
pub(crate) max_pending_per_group: usize,
Expand Down Expand Up @@ -91,6 +93,11 @@ impl Config {
DEFAULT_OPENCODE_TIMEOUT_SECS,
"WN_OPENCODE_TIMEOUT_SECS",
)?);
let opencode_idle_timeout = Duration::from_secs(parse_u64(
lookup("WN_OPENCODE_IDLE_TIMEOUT_SECS"),
DEFAULT_OPENCODE_IDLE_TIMEOUT_SECS,
"WN_OPENCODE_IDLE_TIMEOUT_SECS",
)?);
let request_timeout = Duration::from_secs(parse_u64(
lookup("WN_OPENCODE_REQUEST_TIMEOUT_SECS"),
DEFAULT_REQUEST_TIMEOUT_SECS,
Expand Down Expand Up @@ -129,6 +136,7 @@ impl Config {
account_id_hex,
opencode_bin,
opencode_timeout,
opencode_idle_timeout,
request_timeout,
max_reply_bytes,
max_pending_per_group,
Expand Down Expand Up @@ -222,6 +230,25 @@ mod tests {
assert_eq!(cfg.max_reply_bytes, DEFAULT_MAX_REPLY_BYTES);
}

#[test]
fn config_defaults_idle_and_total_timeouts() {
let cfg = Config::from_pairs(&[("WN_OPENCODE_ALLOWED_SENDERS_HEX", SENDER)]).unwrap();
assert_eq!(cfg.opencode_timeout, Duration::from_secs(3600));
assert_eq!(cfg.opencode_idle_timeout, Duration::from_secs(120));
}

#[test]
fn config_accepts_custom_idle_and_total_timeouts() {
let cfg = Config::from_pairs(&[
("WN_OPENCODE_ALLOWED_SENDERS_HEX", SENDER),
("WN_OPENCODE_TIMEOUT_SECS", "600"),
("WN_OPENCODE_IDLE_TIMEOUT_SECS", "45"),
])
.unwrap();
assert_eq!(cfg.opencode_timeout, Duration::from_secs(600));
assert_eq!(cfg.opencode_idle_timeout, Duration::from_secs(45));
}

#[test]
fn config_uses_terminal_harness_home_default() {
let cfg = Config::from_pairs(&[("WN_OPENCODE_ALLOWED_SENDERS_HEX", SENDER)]).unwrap();
Expand Down
3 changes: 3 additions & 0 deletions integrations/opencode/marmot/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub(crate) enum HarnessError {
ControlRejected { method: &'static str, code: String },
#[error("opencode invocation timed out")]
OpencodeTimedOut,
#[error("opencode went idle without producing output")]
OpencodeIdle,
#[error("opencode stream error")]
OpencodeStream,
#[error("opencode process failed to start")]
Expand All @@ -51,6 +53,7 @@ impl HarnessError {
Self::UnexpectedResponse { .. } => "unexpected_response",
Self::ControlRejected { .. } => "control_rejected",
Self::OpencodeTimedOut => "opencode_timeout",
Self::OpencodeIdle => "opencode_idle",
Self::OpencodeStream => "opencode_stream",
Self::OpencodeSpawn => "opencode_spawn",
Self::Join => "join",
Expand Down
Loading
Loading