fix: drain concurrent assignment inputs and discard stale pending retries

This commit is contained in:
2026-10-12 05:39:33 +09:00
parent 26434bb867
commit 56795fb92c
4 changed files with 199 additions and 83 deletions
+33 -14
View File
@@ -1528,7 +1528,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
#[derive(Default)]
struct OrchestratorAttentionState {
sending: bool,
restore_recheck: bool,
input_recheck: bool,
accepted: Option<OrchestratorAttentionDelivery>,
pending: Option<OrchestratorAttentionDelivery>,
}
@@ -1547,10 +1547,11 @@ impl OrchestratorAttentionReservation {
fn complete(mut self) -> bool {
let state = self.0.take().expect("active attention reservation");
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
// Release and take deferred work atomically. A Restore arriving after
// release can reserve its own check; one arriving before is ours to drain.
// Release and take deferred input checks atomically. A committed input or
// Restore arriving before release is ours to drain; one after release
// can reserve its own check.
state.sending = false;
std::mem::take(&mut state.restore_recheck)
std::mem::take(&mut state.input_recheck)
}
}
@@ -18865,12 +18866,22 @@ fn maybe_dispatch_orchestrator_turn_end(
if orchestrator.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID
&& orchestrator.worker.worker_id == worker_id
{
dispatch_orchestrator_assignment_attention(api, &orchestrator.worker);
// Idle can be a reentrant consequence of this very Runtime send. It is
// not evidence of a new input and must not retry a failed send forever.
// Concurrent committed-input notifications independently defer a check.
with_orchestrator_attention_reservation(
api,
&orchestrator.worker,
false,
|state, pending| {
check_orchestrator_assignment_attention(api, &orchestrator.worker, state, pending);
},
);
}
}
fn dispatch_orchestrator_assignment_attention(api: &WorkspaceApi, worker: &RuntimeWorkerRef) {
with_orchestrator_attention_reservation(api, worker, |state, pending| {
with_orchestrator_attention_reservation(api, worker, true, |state, pending| {
check_orchestrator_assignment_attention(api, worker, state, pending);
});
}
@@ -18878,6 +18889,7 @@ fn dispatch_orchestrator_assignment_attention(api: &WorkspaceApi, worker: &Runti
fn with_orchestrator_attention_reservation(
api: &WorkspaceApi,
worker: &RuntimeWorkerRef,
defer_input_check_if_reserved: bool,
mut check: impl FnMut(
&Arc<Mutex<OrchestratorAttentionState>>,
Option<OrchestratorAttentionDelivery>,
@@ -18888,6 +18900,9 @@ fn with_orchestrator_attention_reservation(
let pending = {
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
if state.sending {
// A no-op scan may already have read its empty snapshot. Retain
// committed input/startup/Restore triggers until its completion.
state.input_recheck |= defer_input_check_if_reserved;
return;
}
state.sending = true;
@@ -18898,8 +18913,8 @@ fn with_orchestrator_attention_reservation(
if !reservation.complete() {
return;
}
// Accepted Restore is an explicit durable-input recheck even when the
// current check was a no-op. A local cache is never backlog authority.
// Drain coalesced input triggers even when the first scan was a no-op.
// No recheck is created by a failed send or its own idle observation.
}
}
@@ -18951,10 +18966,19 @@ fn check_orchestrator_assignment_attention(
.is_ok()
{
send_orchestrator_attention(api, worker, state, delivery);
if state
.lock()
.unwrap_or_else(|p| p.into_inner())
.pending
.is_some()
{
return; // Unknown/failed acceptance needs an explicit later trigger.
}
} else {
state.lock().unwrap_or_else(|p| p.into_inner()).pending = None;
}
return;
// Discarding or acknowledging an old retry must not consume this new-
// input trigger. Continue the common scan in the same reservation.
}
let Ok(inputs) = api
.store
@@ -22969,11 +22993,6 @@ async fn scoped_start_workspace_orchestrator(
{
attention.accepted = None;
}
// A reserved caller may already have decided not to send. Carry
// the explicit Restore check through its completion in that case.
if attention.sending {
attention.restore_recheck = true;
}
}
drop(_guard);
dispatch_orchestrator_assignment_attention(&api, &existing.worker);
@@ -154,7 +154,7 @@ async fn orchestrator_attention_barrier_callers_share_unsent_reservation() {
}
#[tokio::test]
async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_comparison() {
async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_comparison() {
for pause_before_comparison in [true, false] {
let (_dir, api, execution) = attention_test_api().await;
let started = start_attention_test_orchestrator(&api).await;
@@ -187,6 +187,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_
with_orchestrator_attention_reservation(
&api_for_caller,
&worker_for_caller,
true,
|state, pending| {
if checks == 0 && pause_before_comparison {
entered_tx.send(()).unwrap();
@@ -224,7 +225,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_
assert!(state.lock().unwrap().sending);
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
assert_eq!(restored.disposition, "restored");
let deferred = state.lock().unwrap().restore_recheck;
let deferred = state.lock().unwrap().input_recheck;
let old_retired = state.lock().unwrap().accepted.is_none();
let inputs_before_release = execution.take_inputs();
release_tx.send(()).unwrap();
@@ -239,7 +240,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_
let state = state.lock().unwrap();
assert!(state.accepted.is_none());
assert!(!state.sending);
assert!(!state.restore_recheck);
assert!(!state.input_recheck);
}
}
@@ -308,19 +309,19 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() {
"existing"
);
let retry = execution.take_inputs();
assert_eq!(retry.len(), 1);
assert_eq!(retry.len(), 2, "acknowledged retry must also drain the changed backlog");
assert_eq!(retry[0].1, pending.content);
assert_eq!(
execution.take_input_request_ids(),
vec![Some(pending.request_id)]
);
assert!(retry[1].1.contains("Changed backlog"));
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], Some(pending.request_id));
assert_ne!(ids[1], ids[0]);
assert!(state.lock().unwrap().pending.is_none());
assert!(state.lock().unwrap().accepted.is_some());
assert!(attention_pending(&api).is_empty());
use protocol::subscription::SubscriptionWorkerState as S;
maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle);
let changed = execution.take_inputs();
assert_eq!(changed.len(), 1);
assert!(changed[0].1.contains("Changed backlog"));
assert!(execution.take_inputs().is_empty());
}
#[tokio::test]
@@ -36,8 +36,12 @@ async fn delivery_http(
"{method} {path}: {}",
String::from_utf8_lossy(&bytes)
);
if bytes.is_empty() {
Value::Null
} else {
serde_json::from_slice(&bytes).unwrap()
}
}
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
delivery_http(
@@ -330,6 +334,7 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
#[tokio::test]
async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stopping_worker() {
for clear_first in [true, false] {
let dir = tempfile::tempdir().unwrap();
let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "Fence old delivery");
@@ -340,6 +345,7 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
let old_ids = execution.take_input_request_ids();
assert!(!old_attempts.is_empty());
assert!(old_ids.iter().all(|id| id == &old_ids[0]));
if clear_first {
delivery_http(
&api,
"DELETE",
@@ -351,38 +357,30 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
StatusCode::OK,
)
.await;
}
*execution.input_failure.lock().unwrap() = None;
notify_ticket_recipients(&api, TEST_WORKSPACE_ID, &ticket.id, "open", "open", None);
delivery_scan(&api, &worker);
assert!(
execution.take_inputs().is_empty(),
"cleared reservation was sent as new work"
);
// No extra scan, old-operation retry, or synthetic turn end between
// clearing/reissuing the failed input and this committed new Assign.
let reassigned = delivery_assign(&api, &ticket.id, "assign-new").await;
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
assert_ne!(new_assignment, assignment);
let inputs = execution.take_inputs();
assert_eq!(inputs.len(), 1, "new Assign must not need another scan");
assert!(inputs[0].1.contains(new_assignment));
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 1);
assert_ne!(ids[0], old_ids[0], "new assignment reused stale input identity");
assert_eq!(
delivery_assign(&api, &ticket.id, "assign-old").await,
assigned
);
delivery_scan(&api, &worker);
assert!(
execution.take_inputs().is_empty(),
"old operation retry revived cleared work"
);
let reassigned = delivery_assign(&api, &ticket.id, "assign-new").await;
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
assert_ne!(new_assignment, assignment);
assert_eq!(execution.take_inputs().len(), 1);
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 1);
assert_ne!(
ids[0], old_ids[0],
"new assignment reused stale input identity"
);
assert!(execution.take_inputs().is_empty(), "old retry revived work");
let old = delivery_tracking(&api, &ticket.id, assignment);
assert!(!old.current);
assert_eq!(
old.tracking.unwrap().status,
ticket::TicketAssignmentTrackingStatus::Waiting,
"clear must not rewrite the old status to done"
"clear/reissue must not rewrite the old status to done"
);
assert_eq!(
find_workspace_orchestrator(&api).unwrap().worker,
@@ -391,6 +389,104 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
);
delivery_stop(&api, &worker);
}
}
#[tokio::test]
async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_another_trigger() {
for answer in [false, true] {
let dir = tempfile::tempdir().unwrap();
let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "Input racing an empty scan");
if answer {
let assigned = delivery_assign(&api, &ticket.id, "initial-assign").await;
let current = delivery_tracking(
&api,
&ticket.id,
assigned["assignment"]["assignment_id"].as_str().unwrap(),
);
delivery_self_report(&api, &identity, &worker, &current, "waiting", "wait-answer").await;
execution.take_inputs();
execution.take_input_request_ids();
}
assert!(attention_pending(&api).is_empty());
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
let caller_api = api.clone();
let caller_worker = worker.clone();
let caller = scope.spawn(move || {
let mut checks = 0;
with_orchestrator_attention_reservation(
&caller_api,
&caller_worker,
true,
|state, pending| {
check_orchestrator_assignment_attention(
&caller_api, &caller_worker, state, pending,
);
if checks == 0 {
// The production scan has read an empty snapshot but
// still owns its reservation. Race a real HTTP commit.
entered_tx.send(()).unwrap();
release_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
}
checks += 1;
},
);
assert_eq!(checks, 2, "committed input must defer a scan");
});
entered_rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
if answer {
futures::executor::block_on(delivery_http(
&api,
"POST",
&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/thread-events", ticket.id),
Some(serde_json::to_value(NewTicketEvent::new(
TicketEventKind::Comment, "Human answer: go ahead",
)).unwrap()),
StatusCode::NO_CONTENT,
));
} else {
futures::executor::block_on(delivery_assign(&api, &ticket.id, "racing-assign"));
}
// No manual dispatch, Restore, or synthetic Running -> Idle.
assert!(execution.take_inputs().is_empty());
release_tx.send(()).unwrap();
caller.join().unwrap();
});
let inputs = execution.take_inputs();
assert_eq!(inputs.len(), 1, "idle Orchestrator was stranded");
assert!(inputs[0].1.contains(ticket.resource_key.as_deref().unwrap()));
if answer {
assert!(inputs[0].1.contains("Need the human's choice"));
}
assert_eq!(execution.take_input_request_ids().len(), 1);
assert!(attention_pending(&api).is_empty());
delivery_stop(&api, &worker);
}
}
#[tokio::test]
async fn assignment_failed_delivery_and_its_reentrant_idle_do_not_schedule_retry_loop() {
let dir = tempfile::tempdir().unwrap();
let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await;
let ticket = delivery_ticket(&api, "No failed-delivery busy loop");
execution.reject_inputs("Runtime currently cannot accept notifications");
let callback_api = api.clone();
execution.before_input_returns(move |worker| {
use protocol::subscription::SubscriptionWorkerState as S;
maybe_dispatch_orchestrator_turn_end(
&callback_api, &worker.worker_id.to_string(), Some(S::Running), S::Idle,
);
});
delivery_assign(&api, &ticket.id, "assign-failed").await;
assert_eq!(execution.take_inputs().len(), 2, "idle must not add retries beyond the bounded send");
let ids = execution.take_input_request_ids();
assert_eq!(ids.len(), 2);
assert_eq!(ids[0], ids[1]);
assert_eq!(attention_pending(&api).len(), 1, "failure must retain durable input");
delivery_stop(&api, &worker);
}
// The model boundary is scripted, but Controller, notification acceptance,
// canonical session commits, Runtime management and HTTP routing are real.
@@ -24,7 +24,7 @@ Delivery order:
4. Send the saved content with the saved `request_id`, outside the transaction. Response loss retains the same request/payload. Concurrent reservations converge on the same identity, and Runtime request receipts deduplicate actual acceptance.
5. Acknowledge accepted delivery under another IMMEDIATE transaction after rechecking current authority. The acknowledgement never changes handling.
Process-local attention locks prevent concurrent/reentrant send loops; they are not backlog authority. Losing that cache does not lose a delegation or resend acknowledged input. Replacement does not create assignment rows; only pending, unacknowledged inputs are recovered. Accepted history is not replayed into new work merely because a Worker was exchanged.
Process-local attention locks prevent concurrent/reentrant send loops; they are not backlog authority. A committed-input/startup/Restore trigger arriving during a reserved scan is coalesced into an input recheck and drained on reservation release, including when that scan read an empty snapshot. Discarding or acknowledging a pending retry continues the common scan in the same reservation; a successful retry cannot consume the trigger for newer input. Reentrant idle observations caused by a send do not schedule another retry of a failed send. These liveness repairs change no DB transaction or public API boundary. Losing that cache does not lose a delegation or resend acknowledged input. Replacement does not create assignment rows; only pending, unacknowledged inputs are recovered. Accepted history is not replayed into new work merely because a Worker was exchanged.
## Public start/action handoff for T-743