From 8634e2081ddc4f3272fa2b23a77104530cce4025 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Thu, 11 Jun 2026 09:50:12 +0800 Subject: [PATCH] fix terminal finalize lane recovery --- apps/decodex/src/agent/tracker_tool_bridge.rs | 11 +- .../src/agent/tracker_tool_bridge/review.rs | 27 ++ .../tests/review/handoff.rs | 22 ++ .../src/agent/tracker_tool_bridge/tools.rs | 116 ++++++++- apps/decodex/src/orchestrator/execution.rs | 233 ++++++++++++++---- apps/decodex/src/orchestrator/lane_control.rs | 10 +- .../tests/operator/status/http.rs | 80 ++++++ 7 files changed, 441 insertions(+), 58 deletions(-) diff --git a/apps/decodex/src/agent/tracker_tool_bridge.rs b/apps/decodex/src/agent/tracker_tool_bridge.rs index cba6dba41..08baee30c 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge.rs @@ -917,6 +917,15 @@ pub(crate) enum ReviewExecutionMode { Repair, Closeout, } +impl ReviewExecutionMode { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Handoff => "handoff", + Self::Repair => "repair", + Self::Closeout => "closeout", + } + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum TurnCompletionStatus { @@ -932,7 +941,7 @@ pub(crate) enum RunCompletionDisposition { Closeout, } impl RunCompletionDisposition { - fn as_str(self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::ManualAttention => "manual_attention", Self::ReviewHandoff => "review_handoff", diff --git a/apps/decodex/src/agent/tracker_tool_bridge/review.rs b/apps/decodex/src/agent/tracker_tool_bridge/review.rs index 1b9077e98..f5ccd8af0 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/review.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/review.rs @@ -408,6 +408,33 @@ impl<'a> TrackerToolBridge<'a> { } } + pub(crate) fn finalized_completion_disposition( + &self, + ) -> crate::prelude::Result> { + let Some(finalized_path) = *self.finalized_completion_path.borrow() else { + return Ok(None); + }; + let completion_path = self.completion_disposition()?; + + if finalized_path != completion_path { + let Some(review_context) = self.review_context.as_ref() else { + eyre::bail!( + "Review handoff context is unavailable for issue `{}`.", + self.issue.identifier + ); + }; + + eyre::bail!( + "Run `{}` finalized terminal path `{}`, but the recorded terminal path resolved to `{}` after app-server failure.", + review_context.run_id, + finalized_path.as_str(), + completion_path.as_str() + ); + } + + Ok(Some(finalized_path)) + } + pub(crate) fn apply_review_handoff(&self) -> crate::prelude::Result<()> { let Some(review_context) = self.review_context.as_ref() else { eyre::bail!( diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs b/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs index 9d1e87e55..18bf44c7a 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tests/review/handoff.rs @@ -67,6 +67,8 @@ fn terminal_finalize_accepts_matching_review_handoff_path() { })]); let local_repo_inspector = FakeLocalRepoInspector::new(vec![Ok(sample_local_repo())]); let review_context = sample_review_context_in(temp_dir.path()); + let run_id = review_context.run_id.clone(); + let attempt_number = review_context.attempt_number; write_clean_review_checkpoint(&review_context); @@ -94,9 +96,29 @@ fn terminal_finalize_accepts_matching_review_handoff_path() { assert!(review_response.success); assert!(finalize_response.success); + assert_eq!( + bridge + .finalized_completion_disposition() + .expect("finalized disposition should resolve"), + Some(RunCompletionDisposition::ReviewHandoff) + ); DynamicToolHandler::validate_turn_completion(&bridge, "done") .expect("matching finalization should allow the turn to complete"); + + let events = bridge_state_store(&bridge) + .list_private_execution_events(TEST_SERVICE_ID, &issue.id, &run_id, attempt_number) + .expect("private terminal events should read"); + + assert!(events.iter().any(|event| { + event.event_type() == "review_completion_intent" + && event.payload()["path"] == "review_handoff" + && event.payload()["pr_url"] == "https://github.com/hack-ink/decodex/pull/53" + })); + assert!(events.iter().any(|event| { + event.event_type() == "terminal_finalize" + && event.payload()["path"] == "review_handoff" + })); } #[test] diff --git a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs index 9d9209ece..f071799eb 100644 --- a/apps/decodex/src/agent/tracker_tool_bridge/tools.rs +++ b/apps/decodex/src/agent/tracker_tool_bridge/tools.rs @@ -9,7 +9,7 @@ use crate::{ ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, LabelArgs, NormalizedProgressCheckpoint, NormalizedReviewCheckpointPayload, PendingReviewAction, - PendingReviewCompletion, ProgressCheckpointArgs, ReviewCheckpointArgs, + PendingReviewCompletion, ProgressCheckpointArgs, PullRequestDetails, ReviewCheckpointArgs, ReviewCheckpointChecksArgs, ReviewCheckpointFindingArgs, ReviewCheckpointRejectedFindingArgs, ReviewExecutionMode, ReviewHandoffArgs, ReviewHandoffContext, ReviewPolicyPhase, ReviewPolicyStatus, RunCompletionDisposition, @@ -29,6 +29,8 @@ use crate::{ const COMMENT_KIND_MANUAL_ATTENTION: &str = "manual_attention"; const MANUAL_ATTENTION_TERMINAL_PATH: &str = "manual_attention"; const INDEPENDENT_FRESH_CONTEXT_REVIEWER: &str = "independent_fresh_context"; +const REVIEW_COMPLETION_INTENT_EVENT_TYPE: &str = "review_completion_intent"; +const TERMINAL_FINALIZE_EVENT_TYPE: &str = "terminal_finalize"; #[derive(Debug)] struct NormalizedManualAttentionComment { @@ -1417,6 +1419,84 @@ impl<'a> TrackerToolBridge<'a> { }) } + fn append_review_completion_intent( + &self, + review_context: &ReviewHandoffContext, + path: RunCompletionDisposition, + pull_request: &PullRequestDetails, + summary: &str, + ) -> Result<(), String> { + let state_store = self.state_store.ok_or_else(|| { + format!( + "`{}` requires the Decodex runtime state store for issue `{}`.", + self.required_pr_completion_tool_name(), + self.issue.identifier + ) + })?; + + state_store + .append_private_execution_event( + &review_context.service_id, + &self.issue.id, + &review_context.run_id, + review_context.attempt_number, + REVIEW_COMPLETION_INTENT_EVENT_TYPE, + serde_json::json!({ + "path": path.as_str(), + "mode": review_context.mode.as_str(), + "branch": review_context.branch_name.as_str(), + "worktree_path": review_context.worktree_path.as_str(), + "pr_url": pull_request.url.as_str(), + "pr_base_ref": pull_request.base_ref_name.as_str(), + "pr_head_ref": pull_request.head_ref_name.as_str(), + "pr_head_oid": pull_request.head_ref_oid.as_str(), + "summary": summary, + }), + ) + .map(|_| ()) + .map_err(|error| { + format!( + "Failed to persist review completion intent for issue `{}`: {error}", + self.issue.identifier + ) + }) + } + + fn append_terminal_finalize_event( + &self, + review_context: &ReviewHandoffContext, + path: RunCompletionDisposition, + ) -> Result<(), String> { + let state_store = self.state_store.ok_or_else(|| { + format!( + "`{ISSUE_TERMINAL_FINALIZE_TOOL_NAME}` requires the Decodex runtime state store for issue `{}`.", + self.issue.identifier + ) + })?; + + state_store + .append_private_execution_event( + &review_context.service_id, + &self.issue.id, + &review_context.run_id, + review_context.attempt_number, + TERMINAL_FINALIZE_EVENT_TYPE, + serde_json::json!({ + "path": path.as_str(), + "mode": review_context.mode.as_str(), + "branch": review_context.branch_name.as_str(), + "worktree_path": review_context.worktree_path.as_str(), + }), + ) + .map(|_| ()) + .map_err(|error| { + format!( + "Failed to persist terminal finalize intent for issue `{}`: {error}", + self.issue.identifier + ) + }) + } + fn clear_review_policy_state_after_completion( &self, review_context: &ReviewHandoffContext, @@ -1512,6 +1592,14 @@ impl<'a> TrackerToolBridge<'a> { ) { return DynamicToolCallResponse::failure(error); } + if let Err(error) = self.append_review_completion_intent( + review_context, + RunCompletionDisposition::ReviewHandoff, + &pull_request, + &summary, + ) { + return DynamicToolCallResponse::failure(error); + } self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Handoff( PendingReviewAction { pr_url: pull_request.url.clone(), summary }, @@ -1584,6 +1672,14 @@ impl<'a> TrackerToolBridge<'a> { ) { return DynamicToolCallResponse::failure(error); } + if let Err(error) = self.append_review_completion_intent( + review_context, + RunCompletionDisposition::ReviewRepair, + &pull_request, + &summary, + ) { + return DynamicToolCallResponse::failure(error); + } self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Repair( PendingReviewAction { pr_url: pull_request.url.clone(), summary }, @@ -1645,6 +1741,14 @@ impl<'a> TrackerToolBridge<'a> { if let Err(error) = self.validate_closeout_issue_completed_state() { return DynamicToolCallResponse::failure(error); } + if let Err(error) = self.append_review_completion_intent( + review_context, + RunCompletionDisposition::Closeout, + &pull_request, + &summary, + ) { + return DynamicToolCallResponse::failure(error); + } self.pending_review_completion.borrow_mut().replace(PendingReviewCompletion::Closeout( PendingReviewAction { pr_url: pull_request.url.clone(), summary }, @@ -1780,6 +1884,16 @@ impl<'a> TrackerToolBridge<'a> { )); } + let Some(review_context) = self.review_context.as_ref() else { + return DynamicToolCallResponse::failure(format!( + "`{ISSUE_TERMINAL_FINALIZE_TOOL_NAME}` is unavailable for this run." + )); + }; + + if let Err(error) = self.append_terminal_finalize_event(review_context, actual_path) { + return DynamicToolCallResponse::failure(error); + } + self.finalized_completion_path.replace(Some(actual_path)); DynamicToolCallResponse::success(format!( diff --git a/apps/decodex/src/orchestrator/execution.rs b/apps/decodex/src/orchestrator/execution.rs index c9eee11d5..0eb3295af 100644 --- a/apps/decodex/src/orchestrator/execution.rs +++ b/apps/decodex/src/orchestrator/execution.rs @@ -124,6 +124,30 @@ where run_result: &'a AppServerRunResult, } +struct IssueAppServerRun<'a, T> +where + T: IssueTracker, +{ + tracker: &'a T, + project: &'a ServiceConfig, + workflow: &'a WorkflowDocument, + state_store: &'a StateStore, + issue_run: &'a IssueRunPlan, + tracker_tool_bridge: &'a TrackerToolBridge<'a>, + review_context: &'a ReviewHandoffContext, + process_env: &'a AppServerProcessEnv, + transport: &'a str, + continuation_guard: &'a dyn TurnContinuationGuard, + decodex_tool_bridge: &'a DecodexToolBridge<'a>, + phase_goal_controller: &'a dyn PhaseGoalController, + codex_account_provider: Option<&'a dyn CodexAccountProvider>, +} + +enum IssueAppServerRunOutcome { + Completed(AppServerRunResult), + Finalized(RunSummary), +} + struct RepoGatePhaseGoalController<'a> { project: &'a ServiceConfig, workflow: &'a WorkflowDocument, @@ -879,60 +903,26 @@ where DecodexToolBridge::new(&tracker_tool_bridge, build_decodex_run_context(workflow, issue_run)); let phase_goal_controller = build_phase_goal_controller(project, workflow, state_store, issue_run); - let phase_goal_controller_ref = Some(&phase_goal_controller as &dyn PhaseGoalController); - let continuation_user_input = - build_issue_run_continuation_user_input(project, workflow, issue_run, &review_context); - let run_result = agent::execute_app_server_run( - &AppServerRunRequest { - project_id: project.service_id().to_owned(), - run_id: issue_run.run_id.clone(), - issue_id: issue_run.issue.id.clone(), - attempt_number: issue_run.attempt_number, - listen: transport.clone(), - cwd: issue_run.worktree.path.display().to_string(), - developer_instructions: build_run_developer_instructions( - tracker, - project, - workflow, - state_store, - issue_run, - &review_context, - )?, - user_input: build_run_user_input( - tracker, - project, - workflow, - state_store, - issue_run, - &review_context, - ), - max_turns: workflow.frontmatter().execution().max_turns(), - timeout: ACTIVE_RUN_IDLE_TIMEOUT, - process_env: agent_git_credentials.process_env().clone(), - continuation_user_input: Some(continuation_user_input), - activity_marker_path: Some(issue_run.worktree.path.clone()), - resume_thread_id: resolve_resume_thread_id(state_store, issue_run)?, - ephemeral_thread: false, - command_exec_health_check: None, - dynamic_tool_handler: Some(&decodex_tool_bridge), - continuation_guard: Some(&continuation_guard), - phase_goal_controller: phase_goal_controller_ref, - codex_account_provider: codex_account_pool - .as_ref() - .map(|pool| pool as &dyn CodexAccountProvider), - }, + let run_result = match execute_issue_app_server_run(IssueAppServerRun { + tracker, + project, + workflow, state_store, - ) - .map_err(|error| { - preserve_and_promote_app_server_run_failure( - project, - state_store, - issue_run, - workflow, - tracker_tool_bridge.completion_disposition(), - error, - ) - })?; + issue_run, + tracker_tool_bridge: &tracker_tool_bridge, + review_context: &review_context, + process_env: agent_git_credentials.process_env(), + transport: &transport, + continuation_guard: &continuation_guard, + decodex_tool_bridge: &decodex_tool_bridge, + phase_goal_controller: &phase_goal_controller, + codex_account_provider: codex_account_pool + .as_ref() + .map(|pool| pool as &dyn CodexAccountProvider), + })? { + IssueAppServerRunOutcome::Completed(run_result) => run_result, + IssueAppServerRunOutcome::Finalized(summary) => return Ok(summary), + }; if run_result.continuation_pending { return Ok(continuation_boundary_summary(project, workflow, issue_run, &run_result)); @@ -951,6 +941,84 @@ where }) } +fn execute_issue_app_server_run( + input: IssueAppServerRun<'_, T>, +) -> Result +where + T: IssueTracker, +{ + let run_result = match agent::execute_app_server_run( + &AppServerRunRequest { + project_id: input.project.service_id().to_owned(), + run_id: input.issue_run.run_id.clone(), + issue_id: input.issue_run.issue.id.clone(), + attempt_number: input.issue_run.attempt_number, + listen: input.transport.to_owned(), + cwd: input.issue_run.worktree.path.display().to_string(), + developer_instructions: build_run_developer_instructions( + input.tracker, + input.project, + input.workflow, + input.state_store, + input.issue_run, + input.review_context, + )?, + user_input: build_run_user_input( + input.tracker, + input.project, + input.workflow, + input.state_store, + input.issue_run, + input.review_context, + ), + max_turns: input.workflow.frontmatter().execution().max_turns(), + timeout: ACTIVE_RUN_IDLE_TIMEOUT, + process_env: input.process_env.clone(), + continuation_user_input: Some(build_issue_run_continuation_user_input( + input.project, + input.workflow, + input.issue_run, + input.review_context, + )), + activity_marker_path: Some(input.issue_run.worktree.path.clone()), + resume_thread_id: resolve_resume_thread_id(input.state_store, input.issue_run)?, + ephemeral_thread: false, + command_exec_health_check: None, + dynamic_tool_handler: Some(input.decodex_tool_bridge), + continuation_guard: Some(input.continuation_guard), + phase_goal_controller: Some(input.phase_goal_controller), + codex_account_provider: input.codex_account_provider, + }, + input.state_store, + ) { + Ok(run_result) => run_result, + Err(error) => { + if let Some(summary) = maybe_finalize_after_terminalized_app_server_failure( + input.tracker, + input.project, + input.workflow, + input.state_store, + input.issue_run, + input.tracker_tool_bridge, + &error, + )? { + return Ok(IssueAppServerRunOutcome::Finalized(summary)); + } + + return Err(preserve_and_promote_app_server_run_failure( + input.project, + input.state_store, + input.issue_run, + input.workflow, + input.tracker_tool_bridge.completion_disposition(), + error, + )); + }, + }; + + Ok(IssueAppServerRunOutcome::Completed(run_result)) +} + fn build_issue_run_continuation_user_input( project: &ServiceConfig, workflow: &WorkflowDocument, @@ -981,6 +1049,65 @@ fn build_phase_goal_controller<'a>( } } +fn maybe_finalize_after_terminalized_app_server_failure( + tracker: &T, + project: &ServiceConfig, + workflow: &WorkflowDocument, + state_store: &StateStore, + issue_run: &IssueRunPlan, + tracker_tool_bridge: &TrackerToolBridge<'_>, + error: &Report, +) -> Result> +where + T: IssueTracker, +{ + let Some(disposition) = tracker_tool_bridge.finalized_completion_disposition()? else { + return Ok(None); + }; + + state_store.append_private_execution_event( + project.service_id(), + &issue_run.issue.id, + &issue_run.run_id, + issue_run.attempt_number, + "terminal_finalize_app_server_failure_recovery", + serde_json::json!({ + "path": disposition.as_str(), + "source_error": error.to_string(), + "recovery": "apply_terminal_completion_writeback", + }), + )?; + + tracing::warn!( + project_id = project.service_id(), + issue_id = issue_run.issue.id, + issue = issue_run.issue.identifier, + run_id = issue_run.run_id, + attempt = issue_run.attempt_number, + path = disposition.as_str(), + error = %error, + "App-server run failed after terminal finalize; applying terminal completion writeback." + ); + + apply_run_completion_disposition( + tracker, + project, + workflow, + state_store, + issue_run, + tracker_tool_bridge, + )?; + + state_store.record_run_attempt( + &issue_run.run_id, + &issue_run.issue.id, + issue_run.attempt_number, + "succeeded", + )?; + + Ok(Some(run_summary_from_issue_run(project.service_id(), issue_run))) +} + fn phase_goal_kind_from_str(value: &str) -> Option { match value { "implement_to_validation_ready" => Some(PhaseGoalKind::ImplementToValidationReady), diff --git a/apps/decodex/src/orchestrator/lane_control.rs b/apps/decodex/src/orchestrator/lane_control.rs index 8bb0487d0..562088df6 100644 --- a/apps/decodex/src/orchestrator/lane_control.rs +++ b/apps/decodex/src/orchestrator/lane_control.rs @@ -350,7 +350,7 @@ pub(super) fn interrupt_lane_with_state( let run = select_interrupt_lane_run(&snapshot, issue, run_id)?; let soft_interrupt = attempt_soft_lane_interrupt(state_store, project, &run, force, reason, source)?; - let hard_interrupt = if force && soft_interrupt_allows_hard_fallback(&soft_interrupt) { + let hard_interrupt = if force && soft_interrupt_allows_hard_fallback(&soft_interrupt, &run) { Some(attempt_hard_lane_interrupt(state_store, &run, reason)?) } else { None @@ -393,10 +393,14 @@ pub(super) fn steer_lane_with_state( attempt_lane_steer(state_store, project, &run, request) } -fn soft_interrupt_allows_hard_fallback(soft: &LaneSoftInterruptReport) -> bool { +fn soft_interrupt_allows_hard_fallback( + soft: &LaneSoftInterruptReport, + run: &OperatorRunStatus, +) -> bool { match soft.status.as_str() { "pending" | "failed" | "unavailable" => - soft.error_class.as_deref() != Some("lane_not_active"), + soft.error_class.as_deref() != Some("lane_not_active") + || run.process_id.is_some() && run.process_alive != Some(false), "rejected" => soft.error_class.as_deref() == Some("active_lease_missing"), _ => false, } diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 4bcecd269..36d1e67f5 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -2101,6 +2101,86 @@ fn operator_lane_interrupt_api_force_hard_fallbacks_after_active_lease_missing() assert_active_lease_missing_control_audit(&config, &state_store, &fixture, run_id); } +#[cfg(unix)] +#[test] +fn operator_lane_interrupt_api_force_hard_fallbacks_after_lane_not_active_with_live_process() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let registration = ProjectRegistration::from_config( + config.service_id(), + &service_config_path(config.repo_root()), + &config, + true, + "test-fingerprint", + ); + let issue = sample_issue("In Progress", &[]); + let worktree_path = config.worktree_root().join(&issue.identifier); + let project_id = config.service_id().to_owned(); + let issue_identifier = issue.identifier.clone(); + let run_id = "pub-101-attempt-1"; + let body = format!( + r#"{{"projectId":"{project_id}","issue":"{issue_identifier}","runId":"{run_id}","force":true}}"# + ); + let request = format!( + "POST {} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + orchestrator::OPERATOR_LANE_INTERRUPT_ENDPOINT_PATH, + body.len(), + body + ); + let child = Command::new("/bin/sh") + .args(["-c", "exec sleep 60"]) + .spawn() + .expect("lane child process should start"); + let child_process_id = child.id(); + let mut child = child; + + fs::create_dir_all(&worktree_path).expect("worktree should exist"); + state::write_run_activity_marker_for_process(&worktree_path, run_id, 1, child_process_id) + .expect("activity marker should write"); + + state_store.upsert_project(®istration).expect("project should register"); + state_store + .record_run_attempt(run_id, &issue.id, 1, "succeeded") + .expect("run attempt should record"); + state_store.update_run_thread(run_id, "thread-1").expect("thread should record"); + state_store.update_run_turn(run_id, "turn-1").expect("turn should record"); + state_store + .upsert_worktree( + config.service_id(), + &issue.id, + "x/pubfi-pub-101", + &worktree_path.display().to_string(), + ) + .expect("worktree should record"); + + let response = String::from_utf8(orchestrator::build_operator_lane_interrupt_http_response( + &state_store, + request.as_bytes(), + )) + .expect("lane interrupt response should be utf-8"); + let data = operator_json_response_body(&response, "lane interrupt"); + + if orchestrator::process_is_alive(child_process_id) { + child + .kill() + .expect("lane child process should be killable after failed fallback"); + } + + child.wait().expect("lane child process should reap"); + + assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}"); + assert_eq!(data["classification"], "hard_interrupt_fallback"); + assert_eq!(data["softInterrupt"]["status"], "unavailable"); + assert_eq!(data["softInterrupt"]["errorClass"], "lane_not_active"); + assert_eq!(data["hardInterrupt"]["classification"], "hard_interrupt_fallback"); + assert_eq!(data["hardInterrupt"]["status"], "sent"); + assert_eq!( + data["hardInterrupt"]["processId"].as_u64(), + Some(u64::from(child_process_id)) + ); + assert_eq!(data["hardInterrupt"]["processAliveAfter"], false); +} + #[test] fn operator_lane_steer_api_rejects_stale_expected_turn_id() { let (_temp_dir, config, _workflow) = temp_project_layout();