feat(graph): treat timeout: 0 as no wall-clock bound

A zero on any of the five timeout fields (settings.timeout, agent, script,
llm, rag) now disables the bound instead of firing an immediate
"timed out after 0s" or graph bail. Unset values keep their defaults
(agent 300, script 30, rag 120, llm/settings none); non-zero values are
unchanged. One resolution helper, wall_clock(secs) -> Option<Duration>,
feeds all five call sites; the C17 peer-retirement mark and every error
message stay where they were.

The one existing test that relied on timeout: 0 firing
(run_item_chain_marks_identity_finished_when_agent_step_times_out) moves
to timeout: 1 with its 5s hold unchanged.

Design §3.13 / C21.
This commit is contained in:
Dark-Alex-17 committed 2026-09-11 10:56:27 -06:00
1 parent b666b7e494
commit d630c7e30b
7 files changed
+181 -61

No files matched your search

+47 -17
View File
@@ -1,5 +1,6 @@
use super::state::{StateManager, StateRepresentation};
use super::types::ScriptNode;
use super::wall_clock;
use crate::config::paths;
use crate::function::Language;
use anyhow::{Context, Result, anyhow, bail};
@@ -8,7 +9,6 @@ use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
@@ -66,22 +66,25 @@ impl ScriptExecutor {
}
}
let timeout_dur = Duration::from_secs(node.timeout);
let output = timeout(timeout_dur, cmd.output())
.await
.with_context(|| {
format!(
"Script '{}' timed out after {}s",
script_path.display(),
node.timeout
)
})?
.with_context(|| {
format!(
"Failed to spawn script process for '{}'",
script_path.display()
)
})?;
let bound = wall_clock(node.timeout);
let fut = cmd.output();
let output = match bound {
Some(d) => timeout(d, fut).await,
None => Ok(fut.await),
}
.with_context(|| {
format!(
"Script '{}' timed out after {}s",
script_path.display(),
node.timeout
)
})?
.with_context(|| {
format!(
"Failed to spawn script process for '{}'",
script_path.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
@@ -260,6 +263,33 @@ echo '{"quality": 0.85, "issues": 3, "_next": "approve"}'
cleanup(&dir);
}
#[tokio::test]
async fn zero_timeout_is_unbounded() {
if !cmd_available("bash") {
return;
}
let (dir, path) = write_script(
r#"#!/bin/bash
sleep 1.5
echo '{"ok":true}'
"#,
"sh",
);
let mut state = StateManager::new(HashMap::new());
let executor = ScriptExecutor::new(&dir);
executor
.execute(
&node_for(path.file_name().unwrap().to_str().unwrap(), 0),
&mut state,
)
.await
.unwrap_or_else(|e| panic!("zero timeout should not bound the script: {e:#}"));
assert_eq!(state.state().get("ok"), Some(&json!(true)));
cleanup(&dir);
}
#[tokio::test]
async fn bash_script_can_read_state_from_env() {
if !cmd_available("bash") {