From ac1c66513475c582f4b5775df57f7b8e917ab3b1 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Sat, 2 May 2026 20:52:15 +0000 Subject: [PATCH 1/5] 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 70d6541a76c3eff1b91ec5042171085abd5534f3 Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 07:36:23 +1000 Subject: [PATCH 2/5] 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 b98d2fe0e6fd1a2bb172719efb3420c5f800ea23 Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 10:28:19 +1000 Subject: [PATCH 3/5] 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(), From cd9df776e75ae601d6cc2d291c7dd6a08fdade83 Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 12:05:14 +1000 Subject: [PATCH 4/5] fix: make artifact poll timeout actionable --- src/automation/mod.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index c7e1dcf..e093ebf 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -2899,7 +2899,10 @@ fn capture_artifact_with_poll( Ok(path) => break Ok(path), Err(err) => { if poll_start.elapsed() >= poll_deadline { - break Err(err); + break Err(TuttiError::ConfigValidation(format!( + "artifact '{}' did not appear within {}s after completion; verify artifact_glob '{}' or increase wait_timeout_secs. Last capture error: {err}", + artifact_name, poll_secs, pattern + ))); } std::thread::sleep(poll_interval); } @@ -6475,6 +6478,33 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn capture_artifact_with_poll_timeout_is_actionable() { + let dir = std::env::temp_dir().join("tutti-test-capture-poll-timeout"); + 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 err = capture_artifact_with_poll( + &pre_snapshot, + &pattern, + "design_doc", + 0, + std::time::Duration::from_millis(1), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("did not appear within 0s after completion")); + assert!(err.contains("verify artifact_glob")); + assert!(err.contains("increase wait_timeout_secs")); + assert!(err.contains("matched no new files")); + + 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); From 8fb86e44885cedccca6b270bf78b19b3a0d6045f Mon Sep 17 00:00:00 2001 From: adam Date: Mon, 4 May 2026 12:12:53 +1000 Subject: [PATCH 5/5] fix: funnel prompt artifact capture through shared path --- src/automation/mod.rs | 328 ++++++++++++++++++++---------------------- 1 file changed, 157 insertions(+), 171 deletions(-) diff --git a/src/automation/mod.rs b/src/automation/mod.rs index e093ebf..269fadb 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -1331,6 +1331,48 @@ impl<'a> WorkflowExecutor<'a> { } } + if let ( + Some((expanded_pattern, pre_snap)), + Some(art_name), + ) = ( + artifact_pre_snapshot.as_ref(), + artifact_name.as_deref(), + ) { + let result = match capture_and_store_artifact_output( + self.project_root, + &run_id, + expanded_pattern, + pre_snap, + art_name, + post_idle_poll_secs, + ) { + 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(e.to_string()), + stdout: None, + stderr: None, + }); + break; + } + }; + + 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, step_type: "prompt".to_string(), @@ -1478,77 +1520,79 @@ impl<'a> WorkflowExecutor<'a> { agent, )? { - continue; - } - let retry_prompt = "Your previous attempt produced no commit beyond the branch baseline in .tutti/state/auto/branch.json. You are not done yet. If your worktree already has local code changes, do not keep exploring the repo. Review only the existing diff, keep the smallest valid slice, then stage it, commit it, and push it to the target branch now. If there are no useful local changes yet, make the smallest coherent code change now, commit it, and push it. If no valid code change is possible, reply with 'BLOCKED:' and the exact reason."; - if !TmuxSession::session_exists(session_name) { - start_and_wait_ready( - self.project_root, - self.config, - agent, - session_name, - )?; - } - TmuxSession::send_text(session_name, retry_prompt)?; - maybe_submit_buffered_prompt(session_name, retry_prompt)?; + // Fall through to the shared post-step artifact capture + // and success recording below. + } else { + let retry_prompt = "Your previous attempt produced no commit beyond the branch baseline in .tutti/state/auto/branch.json. You are not done yet. If your worktree already has local code changes, do not keep exploring the repo. Review only the existing diff, keep the smallest valid slice, then stage it, commit it, and push it to the target branch now. If there are no useful local changes yet, make the smallest coherent code change now, commit it, and push it. If no valid code change is possible, reply with 'BLOCKED:' and the exact reason."; + if !TmuxSession::session_exists(session_name) { + start_and_wait_ready( + self.project_root, + self.config, + agent, + session_name, + )?; + } + TmuxSession::send_text(session_name, retry_prompt)?; + maybe_submit_buffered_prompt(session_name, retry_prompt)?; - if !wait_for_prompt_activity_or_output( - runtime, - session_name, - retry_prompt, - None, - output_json.as_deref(), - Duration::from_secs(60), - )? { - 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: true, - message: Some( - "implement_code retry did not start activity or produce output within 60s" - .to_string(), - ), - stdout: None, - stderr: None, - }); - break; - } + if !wait_for_prompt_activity_or_output( + runtime, + session_name, + retry_prompt, + None, + output_json.as_deref(), + Duration::from_secs(60), + )? { + 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: true, + message: Some( + "implement_code retry did not start activity or produce output within 60s" + .to_string(), + ), + stdout: None, + stderr: None, + }); + break; + } - let retry_wait = health::wait_for_agent_idle( - runtime, - session_name, - Duration::from_secs((*wait_timeout_secs).max(1)), - Duration::from_secs(5), - Duration::from_secs(10), - )?; - if !retry_wait.is_completed() - || !prompt_step_has_branch_progress( - self.project_root, - agent, - )? - { - 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( - "implement_code completed without a commit beyond the branch baseline" - .to_string(), - ), - stdout: None, - stderr: None, - }); - break; + let retry_wait = health::wait_for_agent_idle( + runtime, + session_name, + Duration::from_secs((*wait_timeout_secs).max(1)), + Duration::from_secs(5), + Duration::from_secs(10), + )?; + if !retry_wait.is_completed() + || !prompt_step_has_branch_progress( + self.project_root, + agent, + )? + { + 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( + "implement_code completed without a commit beyond the branch baseline" + .to_string(), + ), + stdout: None, + stderr: None, + }); + break; + } } } } @@ -1565,37 +1609,13 @@ impl<'a> WorkflowExecutor<'a> { if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) { - let artifact_path = match capture_artifact_with_poll( - pre_snap, - expanded_pattern, - art_name, - post_idle_poll_secs, - Duration::from_secs(1), - ) { - 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( + let result = match capture_and_store_artifact_output( self.project_root, &run_id, + expanded_pattern, + pre_snap, art_name, - &artifact_path, + post_idle_poll_secs, ) { Ok(result) => result, Err(e) => { @@ -1608,10 +1628,7 @@ impl<'a> WorkflowExecutor<'a> { duration_ms: started.elapsed().as_millis() as u64, exit_code: None, timed_out: false, - message: Some(format!( - "artifact capture failed for '{}': {e}", - art_name - )), + message: Some(e.to_string()), stdout: None, stderr: None, }); @@ -1672,38 +1689,13 @@ impl<'a> WorkflowExecutor<'a> { if let (Some((expanded_pattern, pre_snap)), Some(art_name)) = (artifact_pre_snapshot.as_ref(), artifact_name.as_deref()) { - let artifact_path = match capture_artifact_with_poll( - pre_snap, - expanded_pattern, - art_name, - post_idle_poll_secs, - Duration::from_secs(1), - ) { - 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( + let result = match capture_and_store_artifact_output( self.project_root, &run_id, + expanded_pattern, + pre_snap, art_name, - &artifact_path, + post_idle_poll_secs, ) { Ok(result) => result, Err(e) => { @@ -1717,10 +1709,7 @@ impl<'a> WorkflowExecutor<'a> { as u64, exit_code: None, timed_out: false, - message: Some(format!( - "artifact capture failed for '{}': {e}", - art_name - )), + message: Some(e.to_string()), stdout: None, stderr: None, }); @@ -1810,49 +1799,22 @@ 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 capture_result = capture_artifact_with_poll( - pre_snap, + let capture_result = capture_and_store_artifact_output( + self.project_root, + &run_id, expanded_pattern, + pre_snap, art_name, post_idle_poll_secs, - Duration::from_secs(1), ); match capture_result { - Ok(artifact_path) => { - match store_artifact_output( - self.project_root, - &run_id, - art_name, - &artifact_path, - ) { - Ok(result) => { - output_files.insert( - art_name.to_string(), - result.json_path.display().to_string(), - ); - outputs.insert(art_name.to_string(), result.value); - } - 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; - } - } + Ok(result) => { + output_files.insert( + art_name.to_string(), + result.json_path.display().to_string(), + ); + outputs.insert(art_name.to_string(), result.value); } Err(e) => { failed_steps.push(step_index); @@ -2910,6 +2872,30 @@ fn capture_artifact_with_poll( } } +fn capture_and_store_artifact_output( + project_root: &Path, + run_id: &str, + expanded_pattern: &str, + pre_snapshot: &HashSet, + artifact_name: &str, + poll_secs: u64, +) -> Result { + let artifact_path = capture_artifact_with_poll( + pre_snapshot, + expanded_pattern, + artifact_name, + poll_secs, + Duration::from_secs(1), + )?; + + store_artifact_output(project_root, run_id, artifact_name, &artifact_path).map_err(|e| { + TuttiError::ConfigValidation(format!( + "artifact capture failed for '{}': {e}", + artifact_name + )) + }) +} + /// After a step completes, capture the newest artifact that appeared since the pre-step snapshot. fn capture_artifact( pre_snapshot: &HashSet,