diff --git a/server/src/agents/gates.rs b/server/src/agents/gates.rs index f27108d..9c46bd3 100644 --- a/server/src/agents/gates.rs +++ b/server/src/agents/gates.rs @@ -102,13 +102,29 @@ fn run_command_with_timeout( args: &[&str], dir: &Path, ) -> Result<(bool, String), String> { - let mut child = Command::new(program) - .args(args) + // On Linux, execve can return ETXTBSY (26) briefly after a file is written + // before the kernel releases its "write open" state. Retry once after a + // short pause to handle this race condition. + let mut last_err = None; + let mut cmd = Command::new(&program); + cmd.args(args) .current_dir(dir) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .map_err(|e| format!("Failed to spawn command: {e}"))?; + .stderr(std::process::Stdio::piped()); + let mut child = loop { + match cmd.spawn() { + Ok(c) => break c, + Err(e) if e.raw_os_error() == Some(26) => { + // ETXTBSY — wait briefly and retry once + if last_err.is_some() { + return Err(format!("Failed to spawn command: {e}")); + } + last_err = Some(e); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + Err(e) => return Err(format!("Failed to spawn command: {e}")), + } + }; // Drain stdout/stderr in background threads so the pipe buffers never fill. let stdout_handle = child.stdout.take().map(|r| { diff --git a/server/src/http/workflow/story_ops.rs b/server/src/http/workflow/story_ops.rs index 48f317b..fa90d0c 100644 --- a/server/src/http/workflow/story_ops.rs +++ b/server/src/http/workflow/story_ops.rs @@ -183,6 +183,18 @@ pub fn add_criterion_to_file( Ok(()) } +/// Encode a string value as a YAML scalar. +/// +/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted). +/// Everything else is written as a quoted string to avoid ambiguity. +fn yaml_encode_scalar(value: &str) -> String { + match value { + "true" | "false" => value.to_string(), + s if s.parse::().is_ok() => s.to_string(), + s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")), + } +} + /// Update the user story text and/or description in a story file. /// /// At least one of `user_story` or `description` must be provided. @@ -209,7 +221,7 @@ pub fn update_story_in_file( if let Some(fields) = front_matter { for (key, value) in fields { - let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', "")); + let yaml_value = yaml_encode_scalar(value); contents = set_front_matter_field(&contents, key, &yaml_value); } } @@ -589,4 +601,55 @@ mod tests { let contents = fs::read_to_string(&filepath).unwrap(); assert!(contents.contains("agent: \"dev\"")); } + + #[test] + fn update_story_bool_front_matter_written_unquoted() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".storkit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("27_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("blocked".to_string(), "false".to_string()); + update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("blocked: false"), "bool should be unquoted: {result}"); + assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}"); + } + + #[test] + fn update_story_integer_front_matter_written_unquoted() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".storkit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("28_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("retry_count".to_string(), "0".to_string()); + update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}"); + assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}"); + } + + #[test] + fn update_story_bool_front_matter_parseable_after_write() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".storkit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("29_test.md"); + fs::write(&filepath, "---\nname: My Story\n---\n\nNo sections.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("blocked".to_string(), "false".to_string()); + update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap(); + + let contents = fs::read_to_string(&filepath).unwrap(); + let meta = parse_front_matter(&contents).expect("front matter should parse"); + assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field"); + } }