diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs index ffbdeed071e..684951199be 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs @@ -579,9 +579,15 @@ impl ProviderToolBridge { pub fn prepare_turn(&mut self) -> Result<(), ProviderBridgeError> { if !self.pending.is_empty() || !self.completed.is_empty() { - return Err(ProviderBridgeError::invalid( - "cannot prepare the next provider turn while tool calls are active", - )); + // Name the blocking receipts so a recovery that can never start a + // turn identifies the stuck call. IDs and operation names only; no + // tool input or result content leaves the bridge. + return Err(ProviderBridgeError::invalid(format!( + "cannot prepare the next provider turn while tool calls are active \ + (pending: {}; completed: {})", + describe_active_receipts(self.pending.values()), + describe_active_receipts(self.completed.values().map(|entry| &entry.call)), + ))); } // The authoritative result bodies are needed only while the provider // can replay the just-settled turn. Release that bulky data at the @@ -984,6 +990,27 @@ impl ProviderToolBridge { } } +const MAX_DESCRIBED_ACTIVE_RECEIPTS: usize = 4; + +fn describe_active_receipts<'a>( + calls: impl ExactSizeIterator, +) -> String { + let total = calls.len(); + if total == 0 { + return "0".to_owned(); + } + let described = calls + .take(MAX_DESCRIBED_ACTIVE_RECEIPTS) + .map(|call| format!("{}:{}", call.operation_id, call.call_id)) + .collect::>() + .join(", "); + if total > MAX_DESCRIBED_ACTIVE_RECEIPTS { + format!("{total} [{described}, ...]") + } else { + format!("{total} [{described}]") + } +} + fn validate_retained_result(entry: &CompletedToolCall) -> Result<(), ProviderBridgeError> { validate_stable_id(&entry.call.call_id, "retained tool call id")?; validate_stable_id(&entry.result.call_id, "retained tool result call id")?; @@ -2153,6 +2180,47 @@ mod tests { .expect("a new durable run resets the full legacy ledger"); } + #[test] + fn active_receipts_that_block_the_next_turn_are_named() { + let mut bridge = completion_bridge(); + bridge + .begin_call( + "stuck-call".to_owned(), + "paperclip_finish".to_owned(), + json!({"summary": "private summary text"}), + ) + .unwrap(); + + let message = bridge + .prepare_turn() + .expect_err("a pending call blocks the next turn") + .to_string(); + assert!(message.contains("tool calls are active")); + assert!(message.contains("pending: 1 [paperclip_finish:stuck-call]")); + assert!(message.contains("completed: 0")); + assert!(!message.contains("private summary text")); + + bridge + .apply_result(ToolResult { + call_id: "stuck-call".to_owned(), + operation_id: "paperclip_finish".to_owned(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + let message = bridge + .prepare_turn() + .expect_err("an unsettled completed call blocks the next turn") + .to_string(); + assert!(message.contains("pending: 0;")); + assert!(message.contains("completed: 1 [paperclip_finish:stuck-call]")); + + bridge.settle_turn("provider_turn_terminated").unwrap(); + bridge + .prepare_turn() + .expect("settled receipts no longer block the turn"); + } + #[test] fn provider_restart_rotates_only_an_idle_replay_epoch() { let operation = AuthorizedTool { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs index 61a0a022502..4886a50879d 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/provider_bridge.rs @@ -416,6 +416,66 @@ fn completed_receipts_are_exact_until_the_controlled_turn_limit() { recovered.validate_recovered().unwrap(); } +#[test] +fn blocked_turn_error_names_at_most_four_receipts_per_set() { + let mut bridge = ProviderToolBridge::default(); + bridge.prepare(tools("computed")).unwrap(); + for index in 0..4 { + bridge + .begin_call( + format!("call-{index}"), + "get_task_context".into(), + json!({}), + ) + .unwrap(); + } + + let message = bridge + .prepare_turn() + .expect_err("pending calls block the next turn") + .to_string(); + assert!(message.contains( + "pending: 4 [get_task_context:call-0, get_task_context:call-1, \ + get_task_context:call-2, get_task_context:call-3]" + )); + assert!(!message.contains("...")); + + bridge + .begin_call("call-4".into(), "get_task_context".into(), json!({})) + .unwrap(); + let message = bridge + .prepare_turn() + .expect_err("pending calls block the next turn") + .to_string(); + assert!(message.contains( + "pending: 5 [get_task_context:call-0, get_task_context:call-1, \ + get_task_context:call-2, get_task_context:call-3, ...]" + )); + assert!(!message.contains("call-4")); + assert!(message.contains("completed: 0")); + + for index in 0..5 { + bridge + .apply_result(ToolResult { + call_id: format!("call-{index}"), + operation_id: "get_task_context".into(), + result: json!({"ok": true}), + is_error: false, + }) + .unwrap(); + } + let message = bridge + .prepare_turn() + .expect_err("unsettled completed calls block the next turn") + .to_string(); + assert!(message.contains("pending: 0;")); + assert!(message.contains( + "completed: 5 [get_task_context:call-0, get_task_context:call-1, \ + get_task_context:call-2, get_task_context:call-3, ...]" + )); + assert!(!message.contains("call-4")); +} + #[test] fn turn_settlement_cannot_be_blocked_by_completed_value_pressure() { let mut bridge = ProviderToolBridge::default();