From 5884891c0ee2858fa70eb9b19ec9a610042d90c4 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Sat, 2 May 2026 20:52:15 +0000 Subject: [PATCH 1/3] fix(automation): poll artifact_glob after wait_for_idle settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-step artifact capture in the wait_for_idle = true branch was a single-shot check after a 2s sleep. wait_for_agent_idle can return as soon as the runtime adapter detects a completion signal (Claude Code emits this at end-of-turn), which can fire before the agent's last file write is flushed to disk — or before the agent has even started writing the file referenced in its final response. Replace the sleep+check with a bounded polling loop (1s cadence, window = max(wait_timeout_secs / 30, 5s)..60s). The artifact-polling mode branch (active when wait_for_idle = false) already uses this pattern at lines 1080-1255; this aligns the wait_for_idle = true path with that proven approach. Fixes #120 --- src/automation/mod.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 859f1b8..6eccb2e 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -1714,10 +1714,34 @@ impl<'a> WorkflowExecutor<'a> { if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) { - // Brief settle time for filesystem I/O - std::thread::sleep(Duration::from_secs(2)); + // Poll for the artifact to materialize. wait_for_agent_idle + // can return as soon as the runtime emits a completion signal, + // which may be before the agent's file write is flushed to disk. + // Use a short bounded poll window proportional to wait_timeout_secs + // (capped at 60s) so that fast steps stay fast and slow flushes + // don't false-fail. + // See https://github.com/nutthouse/tutti/issues/120 + let post_idle_poll_secs = std::cmp::min( + std::cmp::max(*step_wait_timeout / 30, 5), + 60, + ); + let poll_interval = Duration::from_secs(1); + let poll_deadline = Duration::from_secs(post_idle_poll_secs); + let poll_start = std::time::Instant::now(); + + let capture_result = loop { + match capture_artifact(pre_snap, expanded_pattern, art_name) { + Ok(p) => break Ok(p), + Err(e) => { + if poll_start.elapsed() >= poll_deadline { + break Err(e); + } + std::thread::sleep(poll_interval); + } + } + }; - match capture_artifact(pre_snap, expanded_pattern, art_name) { + match capture_result { Ok(artifact_path) => { match store_artifact_output( self.project_root, From 24e042471887e7312d0da1ab598acaa08305f182 Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 07:36:23 +1000 Subject: [PATCH 2/3] fix: reuse artifact polling on implement_code exits --- src/automation/mod.rs | 157 ++++++++++++++++++++++++++++-------------- 1 file changed, 107 insertions(+), 50 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 6eccb2e..03e3875 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -1035,6 +1035,7 @@ impl<'a> WorkflowExecutor<'a> { } else { None }; + let post_idle_poll_secs = post_idle_artifact_poll_secs(*step_wait_timeout); let baseline_pane_hash = TmuxSession::capture_pane(session_name, PROMPT_CAPTURE_LINES) @@ -1563,23 +1564,25 @@ impl<'a> WorkflowExecutor<'a> { // Run artifact capture before early success exit if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) + && let Ok(artifact_path) = capture_artifact_with_poll( + pre_snap, + expanded_pattern, + art_name, + post_idle_poll_secs, + Duration::from_secs(1), + ) + && let Ok(result) = store_artifact_output( + self.project_root, + &run_id, + art_name, + &artifact_path, + ) { - std::thread::sleep(Duration::from_secs(2)); - if let Ok(artifact_path) = - capture_artifact(pre_snap, expanded_pattern, art_name) - && let Ok(result) = store_artifact_output( - self.project_root, - &run_id, - art_name, - &artifact_path, - ) - { - output_files.insert( - art_name.to_string(), - result.json_path.display().to_string(), - ); - outputs.insert(art_name.to_string(), result.value); - } + output_files.insert( + art_name.to_string(), + result.json_path.display().to_string(), + ); + outputs.insert(art_name.to_string(), result.value); } step_results.push(StepResult { index: step_index, @@ -1627,23 +1630,25 @@ impl<'a> WorkflowExecutor<'a> { // Run artifact capture before early success exit if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) + && let Ok(artifact_path) = capture_artifact_with_poll( + pre_snap, + expanded_pattern, + art_name, + post_idle_poll_secs, + Duration::from_secs(1), + ) + && let Ok(result) = store_artifact_output( + self.project_root, + &run_id, + art_name, + &artifact_path, + ) { - std::thread::sleep(Duration::from_secs(2)); - if let Ok(artifact_path) = - capture_artifact(pre_snap, expanded_pattern, art_name) - && let Ok(result) = store_artifact_output( - self.project_root, - &run_id, - art_name, - &artifact_path, - ) - { - output_files.insert( - art_name.to_string(), - result.json_path.display().to_string(), - ); - outputs.insert(art_name.to_string(), result.value); - } + output_files.insert( + art_name.to_string(), + result.json_path.display().to_string(), + ); + outputs.insert(art_name.to_string(), result.value); } step_results.push(StepResult { index: step_index, @@ -1721,25 +1726,13 @@ impl<'a> WorkflowExecutor<'a> { // (capped at 60s) so that fast steps stay fast and slow flushes // don't false-fail. // See https://github.com/nutthouse/tutti/issues/120 - let post_idle_poll_secs = std::cmp::min( - std::cmp::max(*step_wait_timeout / 30, 5), - 60, + let capture_result = capture_artifact_with_poll( + pre_snap, + expanded_pattern, + art_name, + post_idle_poll_secs, + Duration::from_secs(1), ); - let poll_interval = Duration::from_secs(1); - let poll_deadline = Duration::from_secs(post_idle_poll_secs); - let poll_start = std::time::Instant::now(); - - let capture_result = loop { - match capture_artifact(pre_snap, expanded_pattern, art_name) { - Ok(p) => break Ok(p), - Err(e) => { - if poll_start.elapsed() >= poll_deadline { - break Err(e); - } - std::thread::sleep(poll_interval); - } - } - }; match capture_result { Ok(artifact_path) => { @@ -2803,6 +2796,33 @@ fn snapshot_artifact_glob(pattern: &str) -> Result> { Ok(paths.filter_map(|p| p.ok()).collect()) } +fn post_idle_artifact_poll_secs(wait_timeout_secs: u64) -> u64 { + (wait_timeout_secs / 30).clamp(5, 60) +} + +fn capture_artifact_with_poll( + pre_snapshot: &HashSet, + pattern: &str, + artifact_name: &str, + poll_secs: u64, + poll_interval: Duration, +) -> Result { + let poll_deadline = Duration::from_secs(poll_secs); + let poll_start = std::time::Instant::now(); + + loop { + match capture_artifact(pre_snapshot, pattern, artifact_name) { + Ok(path) => break Ok(path), + Err(err) => { + if poll_start.elapsed() >= poll_deadline { + break Err(err); + } + std::thread::sleep(poll_interval); + } + } + } +} + /// After a step completes, capture the newest artifact that appeared since the pre-step snapshot. fn capture_artifact( pre_snapshot: &HashSet, @@ -6341,6 +6361,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn capture_artifact_with_poll_waits_for_late_file() { + let dir = std::env::temp_dir().join("tutti-test-capture-poll"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + + let pattern = format!("{}/*.md", dir.display()); + let pre_snapshot = snapshot_artifact_glob(&pattern).unwrap(); + let late_file = dir.join("design-late.md"); + let writer_path = late_file.clone(); + + let writer = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(25)); + std::fs::write(&writer_path, "late artifact").unwrap(); + }); + + let result = capture_artifact_with_poll( + &pre_snapshot, + &pattern, + "design_doc", + 1, + std::time::Duration::from_millis(5), + ) + .unwrap(); + writer.join().unwrap(); + assert_eq!(result, late_file); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn post_idle_artifact_poll_secs_is_bounded() { + assert_eq!(post_idle_artifact_poll_secs(30), 5); + assert_eq!(post_idle_artifact_poll_secs(1800), 60); + assert_eq!(post_idle_artifact_poll_secs(3600), 60); + } + #[test] fn capture_artifact_multiple_new_files_picks_newest() { let dir = std::env::temp_dir().join("tutti-test-capture-multi"); From 770d9c04995f8ff59a04ac71f307fc1be0aa658d Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 10:28:19 +1000 Subject: [PATCH 3/3] fix: fail required artifact capture on early exits --- src/automation/mod.rs | 104 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 10 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index 03e3875..c7e1dcf 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -1564,20 +1564,61 @@ impl<'a> WorkflowExecutor<'a> { // Run artifact capture before early success exit if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) - && let Ok(artifact_path) = capture_artifact_with_poll( + { + let artifact_path = match capture_artifact_with_poll( pre_snap, expanded_pattern, art_name, post_idle_poll_secs, Duration::from_secs(1), - ) - && let Ok(result) = store_artifact_output( + ) { + Ok(path) => path, + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() as u64, + exit_code: None, + timed_out: false, + message: Some(e.to_string()), + stdout: None, + stderr: None, + }); + break; + } + }; + + let result = match store_artifact_output( self.project_root, &run_id, art_name, &artifact_path, - ) - { + ) { + Ok(result) => result, + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() as u64, + exit_code: None, + timed_out: false, + message: Some(format!( + "artifact capture failed for '{}': {e}", + art_name + )), + stdout: None, + stderr: None, + }); + break; + } + }; + output_files.insert( art_name.to_string(), result.json_path.display().to_string(), @@ -1630,20 +1671,63 @@ impl<'a> WorkflowExecutor<'a> { // Run artifact capture before early success exit if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) - && let Ok(artifact_path) = capture_artifact_with_poll( + { + let artifact_path = match capture_artifact_with_poll( pre_snap, expanded_pattern, art_name, post_idle_poll_secs, Duration::from_secs(1), - ) - && let Ok(result) = store_artifact_output( + ) { + Ok(path) => path, + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() + as u64, + exit_code: None, + timed_out: false, + message: Some(e.to_string()), + stdout: None, + stderr: None, + }); + break; + } + }; + + let result = match store_artifact_output( self.project_root, &run_id, art_name, &artifact_path, - ) - { + ) { + Ok(result) => result, + Err(e) => { + failed_steps.push(step_index); + success = false; + step_results.push(StepResult { + index: step_index, + step_type: "prompt".to_string(), + status: StepStatus::Failed, + duration_ms: started.elapsed().as_millis() + as u64, + exit_code: None, + timed_out: false, + message: Some(format!( + "artifact capture failed for '{}': {e}", + art_name + )), + stdout: None, + stderr: None, + }); + break; + } + }; + output_files.insert( art_name.to_string(), result.json_path.display().to_string(),