From 67d5a473333eee5ca2c053225cf3ca293d3bf494 Mon Sep 17 00:00:00 2001 From: "ilblackdragon@gmail.com" Date: Sat, 28 Mar 2026 14:20:16 -0700 Subject: [PATCH] fix(engine): transition thread to Waiting on NeedApproval The orchestrator Python returned {"outcome": "need_approval"} without calling __transition_to__("waiting"), leaving the thread in Running state. When the user later approved/denied, resume_thread rejected it with "thread is not resumable from Running". - Add __transition_to__("waiting", "approval needed") in both code-step and action-call approval paths in default.py - Add Rust safety net in loop_engine.rs: if orchestrator returns NeedApproval but thread isn't Waiting, force the transition Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/ironclaw_engine/orchestrator/default.py | 2 ++ .../ironclaw_engine/src/executor/loop_engine.rs | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/ironclaw_engine/orchestrator/default.py b/crates/ironclaw_engine/orchestrator/default.py index ae1f7a15..30780dd8 100644 --- a/crates/ironclaw_engine/orchestrator/default.py +++ b/crates/ironclaw_engine/orchestrator/default.py @@ -339,6 +339,7 @@ def run_loop(context, goal, actions, state, config): "nudge_count": nudge_count, "consecutive_errors": consecutive_errors, }) + __transition_to__("waiting", "approval needed") return { "outcome": "need_approval", "action_name": approval.get("action_name", ""), @@ -382,6 +383,7 @@ def run_loop(context, goal, actions, state, config): "nudge_count": nudge_count, "consecutive_errors": consecutive_errors, }) + __transition_to__("waiting", "approval needed") return { "outcome": "need_approval", "action_name": name, diff --git a/crates/ironclaw_engine/src/executor/loop_engine.rs b/crates/ironclaw_engine/src/executor/loop_engine.rs index 9240d6a8..c62fcdd7 100644 --- a/crates/ironclaw_engine/src/executor/loop_engine.rs +++ b/crates/ironclaw_engine/src/executor/loop_engine.rs @@ -305,6 +305,22 @@ impl ExecutionLoop { .await; } let _ = &orch_result.tokens_used; + + // Safety net: if the orchestrator returned NeedApproval but + // didn't transition to Waiting, do it now so resume_thread works. + if matches!(orch_result.outcome, ThreadOutcome::NeedApproval { .. }) + && self.thread.state != ThreadState::Waiting + { + debug!( + thread_id = %self.thread.id, + state = ?self.thread.state, + "orchestrator returned NeedApproval without transitioning to Waiting" + ); + let _ = self + .thread + .transition_to(ThreadState::Waiting, Some("approval needed".into())); + } + self.clear_runtime_checkpoint(); self.persist_runtime_state(None, &mut persisted_event_count) .await?;