Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
266 changes: 265 additions & 1 deletion crates/ironclaw_reborn_composition/src/slack_delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,39 @@ pub struct SlackFinalReplyDeliveryObserver {
/// Caps Slack API usage per blocked run; bounded FIFO eviction keeps memory O(1);
/// a false-negative after eviction just means one extra hint, harmless.
hint_seen: HintSeenSet,
/// Single-flight guard: at most one live `deliver_final_reply` loop per run_id.
///
/// A gate-resolution ack (`ApprovalResolution(Allow)` / `AuthResolution(Allowed)`)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Comments name non-existent resolution variants.

The new production comment describes ApprovalResolution(Allow) / AuthResolution(Allowed), but the local adapter types use ApprovalDecision::{ApproveOnce, AlwaysAllow} and AuthResolutionResult::{CredentialProvided, CallbackCompleted, Denied}. The same invented AuthResolution(Allowed) name is repeated in the new e2e comments, making grep/navigation for the actual auth-resolution path misleading.

Fix: Use the local type names in comments, e.g. ApprovalResolution with an approving ApprovalDecision and AuthResolutionResult::CallbackCompleted or CredentialProvided, or use plain prose without code-formatted pseudo-variants.

/// carries the same `submitted_run_id` as the original user-message ack because it
/// resumes the pre-existing run rather than creating a new one. Without this guard,
/// each resolution ack would spawn a second delivery loop for the same run while the
/// original loop is still watching — N resolutions ⇒ N+1 concurrent loops ⇒ gate N
/// posted N times. The original loop detects the unblock and posts the next gate
/// exactly once, so resolution-ack loops are always redundant duplicates.
active_delivery_run_ids: Mutex<HashSet<TurnRunId>>,
}

/// RAII guard that removes a `run_id` from `active_delivery_run_ids` on drop.
///
/// Acquired before the delivery semaphore permit so that a concurrent ack for
/// the same run_id is rejected immediately — without competing for a permit and
/// without the TOCTOU window that existed when the permit was acquired first.
///
/// Panic-safe: `Drop` uses `unwrap_or_else(|e| e.into_inner())` to tolerate a
/// poisoned mutex, so the run_id is always removed even if `deliver_final_reply`
/// panics.
struct RunDeliveryGuard<'a> {
set: &'a Mutex<HashSet<TurnRunId>>,
run_id: TurnRunId,
}

impl Drop for RunDeliveryGuard<'_> {
fn drop(&mut self) {
self.set
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&self.run_id);
}
}

impl SlackFinalReplyDeliveryObserver {
Expand All @@ -155,6 +188,7 @@ impl SlackFinalReplyDeliveryObserver {
settings,
delivery_permits: Arc::new(Semaphore::new(settings.max_concurrent_deliveries.get())),
hint_seen: Mutex::new((VecDeque::new(), HashSet::new())),
active_delivery_run_ids: Mutex::new(HashSet::new()),
}
}

Expand Down Expand Up @@ -1054,14 +1088,68 @@ impl ImmediateAckWorkflowObserver for SlackFinalReplyDeliveryObserver {
}
return;
}
// Single-flight guard: at most one live delivery loop per run_id.
//
// A gate-resolution ack (ApprovalResolution(Allow) / AuthResolution(Allowed))
// carries the same submitted_run_id as the original user-message ack because
// it resumes the pre-existing run. The original loop is still alive and will
// observe the unblock on its next poll, posting the next gate or final reply
// exactly once. Spawning a second loop for the same run_id would produce
// duplicate posts (N resolutions ⇒ N+1 loops ⇒ gate N posted N times).
//
// `should_deliver_after_ack` only filters Deny resolutions; Allow resolutions
// pass through here. We guard by run_id rather than by ack type so the fix
// is robust to future ack variants that may also target an existing run.
//
// IMPORTANT: the guard is checked and inserted BEFORE acquiring the delivery
// semaphore permit. Without this ordering, a second ack (L2) for the same
// run_id could block on the permit while L1 is delivering; when L1 releases
// the permit and removes the run_id, L2 would wake and pass a now-empty guard
// set — the exact TOCTOU race this ordering closes.
//
// The `RunDeliveryGuard` RAII type ensures the run_id is removed on drop even

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Panic-safe guard release is not covered.

The new guard is explicitly documented as preventing a permanent delivery block if deliver_final_reply panics, but the added tests cover timeout/error returns rather than the panic/unwinding path.

Fix: Add tests::slack_delivery::guard_is_released_after_delivery_panic_so_subsequent_ack_proceeds covering a deliver_final_reply panic after run_id guard insertion.

// if `deliver_final_reply` panics, preventing a permanent delivery block.
let _delivery_guard = if let Some(run_id) = submitted_run_id(&ack) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Low — Single-flight admission is split from delivery eligibility.

The new guard decides whether an ack may occupy the per-run single-flight slot by checking only submitted_run_id, then deliver_final_reply later re-applies the actual delivery predicate with should_deliver_after_ack. That leaves the admission contract spread across two branches and makes future ack variants easy to route through the guard while still being no-ops inside delivery.

Fix: Compute the eligible delivery run once before taking the guard, then pass that run_id into a delivery helper so the guard, semaphore, and delivery loop share one admission path.

let already_delivering = {
let mut guard = self
.active_delivery_run_ids
.lock()
.unwrap_or_else(|e| e.into_inner());
if guard.contains(&run_id) {
true
} else {
guard.insert(run_id);
false
}
};
Comment on lines +1113 to +1124

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The manual check-and-insert pattern on the HashSet can be simplified to a single insert call. HashSet::insert returns true if the element was not present (newly inserted) and false if it was already present. Using this directly is more idiomatic in Rust and avoids a redundant double-lookup (hashing and searching twice) on the set under the mutex lock.

            let already_delivering = !self
                .active_delivery_run_ids
                .lock()
                .unwrap_or_else(|e| e.into_inner())
                .insert(run_id);

if already_delivering {
tracing::debug!(
target = "ironclaw::reborn::slack_delivery",
%run_id,
"skipping redundant delivery loop: a loop is already watching this run"
);
return;
Comment on lines +1125 to +1131

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not single-flight auth-denial acks

When a Slack user replies auth deny ... while the original delivery loop is still active after posting the auth prompt, the workflow accepts an AuthResolution::Denied ack with the same submitted_run_id. This early return treats that ack as redundant and skips deliver_final_reply, so the is_accepted_auth_denial() path that posts SLACK_AUTH_CANCELED_MESSAGE never runs; the original loop only observes the run becoming Cancelled and returns without any notification. Please special-case accepted auth denials before this guard, or allow them through even when the run_id is already active.

Useful? React with 👍 / 👎.

}
Some(RunDeliveryGuard {
set: &self.active_delivery_run_ids,
run_id,
})
} else {
None
};
let Ok(_permit) = self.delivery_permits.clone().acquire_owned().await else {
tracing::warn!(
target = "ironclaw::reborn::slack_delivery",
"Slack final reply delivery skipped because delivery semaphore was closed"
);
return;
};
if let Err(error) = self.deliver_final_reply(envelope.clone(), ack).await {
let delivery_result = self.deliver_final_reply(envelope.clone(), ack).await;
// `_delivery_guard` is dropped here automatically, removing the run_id from
// `active_delivery_run_ids` even if `deliver_final_reply` returned an error.
// Explicit drop makes the cleanup point visible at the call site.
drop(_delivery_guard);
if let Err(error) = delivery_result {
tracing::warn!(
target = "ironclaw::reborn::slack_delivery",
error = %error,
Expand Down Expand Up @@ -5254,4 +5342,180 @@ mod tests {
"GetRunStateRequest scope must be derived from the authorized binding"
);
}

/// A delivery that errors or times out must NOT leave the run_id in
/// `active_delivery_run_ids` permanently. A subsequent `observe_workflow_ack`
/// for the same run_id must proceed to delivery instead of being rejected by
/// the guard.
///
/// Test setup: the coordinator always returns `Running`; `max_wait = 1 ms`
/// forces a timeout on every attempt. After the first timeout the RAII guard
/// drops the run_id, so the second attempt reaches `wait_for_actionable` and
/// polls `get_run_state` at least once more.
///
/// If the guard were NOT released after an error, the second call would return
/// early without ever calling `get_run_state`, and the total call count would
/// equal the first attempt's count.
#[tokio::test]
async fn guard_is_released_after_delivery_error_so_subsequent_ack_proceeds() {
let install = "test-install";
let coordinator = Arc::new(ScriptedTurnCoordinator::with_single_status(
TurnStatus::Running,
));
let egress = Arc::new(FakeProtocolHttpEgress::new(vec!["slack.com".to_string()]));
egress.allow_credential_handle("slack_bot_token");
let outbound = Arc::new(InMemoryOutboundStateStore::default());

// Build an observer with a very short max_wait so delivery times out quickly.
let thread_service = Arc::new(InMemorySessionThreadService::default());
let services = SlackFinalReplyDeliveryServices {
binding_service: Arc::new(
ironclaw_product_workflow::FakeConversationBindingService::new(),
),
thread_service,
turn_coordinator: coordinator.clone(),
outbound_store: outbound.clone(),
route_store: Arc::new(InMemoryDeliveredGateRouteStore::default()),
communication_preferences: outbound,
adapter: test_adapter(install),
egress: egress.clone(),
delivery_sink: Arc::new(FakeOutboundDeliverySink::default()),
auth_challenges: None,
};
let settings = SlackFinalReplyDeliverySettings {
poll_interval: std::time::Duration::ZERO,
max_wait: std::time::Duration::from_millis(1),
max_concurrent_deliveries: NonZeroUsize::new(4).unwrap(),
max_pending_deliveries: NonZeroUsize::new(8).unwrap(),
};
let observer = SlackFinalReplyDeliveryObserver::with_settings(services, settings);

let run_id = TurnRunId::new();
let ack = ProductInboundAck::Accepted {
accepted_message_ref: AcceptedMessageRef::new("msg:guard-release-test")
.expect("accepted message ref"), // safety: static test ref is valid.
submitted_run_id: run_id,
};
let env = envelope(user_message_payload());

// First delivery: times out; guard must be released on return.
observer
.observe_workflow_ack(env.clone(), ack.clone())
.await;
let calls_after_first = {
let c = coordinator.calls.lock().expect("coordinator calls lock");
*c
};
assert!(
calls_after_first >= 1,
"first delivery attempt must poll get_run_state at least once; got {calls_after_first}"
);

// Second delivery for the same run_id: if the guard were not released the
// observer would return early and get_run_state would not be called again.
observer.observe_workflow_ack(env, ack).await;
let calls_after_second = {
let c = coordinator.calls.lock().expect("coordinator calls lock");
*c
};
assert!(
calls_after_second > calls_after_first,
"second delivery attempt must reach get_run_state (guard was not released after the first error); \
calls after first={calls_after_first}, calls after second={calls_after_second}"
);
}

/// Single-flight fanout regression: while one delivery loop is in flight for a
/// run_id, a second ack carrying the SAME run_id must be rejected by the guard
/// WITHOUT competing for the delivery semaphore permit.
///
/// Real-world case: an `AuthResolution(Allowed)` / `ApprovalResolution(Allow)`
/// resolution resumes the pre-existing run and is ack'd with the original
/// `submitted_run_id`. The original loop is still watching, so a second loop
/// would post gate N a second time (N resolutions ⇒ N+1 loops).
///
/// This locks the TOCTOU ordering specifically: with `max_concurrent_deliveries
/// = 1`, the first (blocked) delivery holds the only permit. If the guard were
/// checked AFTER acquiring the permit, the second call would block on the
/// semaphore and the `timeout` below would elapse. Because the guard is checked
/// and inserted BEFORE the permit, the second call returns immediately.
#[tokio::test]
async fn concurrent_ack_for_same_run_id_is_rejected_before_acquiring_permit() {
let install = "test-install";
// Always-Running coordinator + large max_wait ⇒ the first delivery blocks in
// wait_for_actionable, holding the single delivery permit for the test's life.
let coordinator = Arc::new(ScriptedTurnCoordinator::with_single_status(
TurnStatus::Running,
));
let egress = Arc::new(FakeProtocolHttpEgress::new(vec!["slack.com".to_string()]));
egress.allow_credential_handle("slack_bot_token");
let outbound = Arc::new(InMemoryOutboundStateStore::default());
let thread_service = Arc::new(InMemorySessionThreadService::default());
let services = SlackFinalReplyDeliveryServices {
binding_service: Arc::new(
ironclaw_product_workflow::FakeConversationBindingService::new(),
),
thread_service,
turn_coordinator: coordinator.clone(),
outbound_store: outbound.clone(),
route_store: Arc::new(InMemoryDeliveredGateRouteStore::default()),
communication_preferences: outbound,
adapter: test_adapter(install),
egress: egress.clone(),
delivery_sink: Arc::new(FakeOutboundDeliverySink::default()),
auth_challenges: None,
};
let settings = SlackFinalReplyDeliverySettings {
poll_interval: std::time::Duration::from_millis(1),
max_wait: std::time::Duration::from_secs(60),
max_concurrent_deliveries: NonZeroUsize::new(1).unwrap(),
max_pending_deliveries: NonZeroUsize::new(8).unwrap(),
};
let observer = Arc::new(SlackFinalReplyDeliveryObserver::with_settings(
services, settings,
));

let run_id = TurnRunId::new();
let make_ack = |slug: &str| ProductInboundAck::Accepted {
accepted_message_ref: AcceptedMessageRef::new(slug).expect("accepted message ref"),
submitted_run_id: run_id,
};
let env = envelope(user_message_payload());

// First delivery: acquires the guard + the only permit, then blocks.
let first = {
let observer = observer.clone();
let env = env.clone();
let ack = make_ack("msg:first");
tokio::spawn(async move { observer.observe_workflow_ack(env, ack).await })
};

// Wait until the first loop registered the run_id in the single-flight set.
loop {
let registered = observer
.active_delivery_run_ids
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&run_id);
if registered {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}

// Second ack for the SAME run_id while the first still holds the permit.
// Must return promptly via the guard skip, NOT block on the semaphore.
let second = tokio::time::timeout(
std::time::Duration::from_secs(2),
observer.observe_workflow_ack(env, make_ack("msg:second")),
)
.await;
assert!(
second.is_ok(),
"second ack for an in-flight run_id must be rejected by the single-flight guard \
before acquiring the delivery permit; it blocked on the semaphore instead"
);

first.abort();
}
}
Loading
Loading