fix: drain concurrent assignment inputs and discard stale pending retries
This commit is contained in:
@@ -1528,7 +1528,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct OrchestratorAttentionState {
|
struct OrchestratorAttentionState {
|
||||||
sending: bool,
|
sending: bool,
|
||||||
restore_recheck: bool,
|
input_recheck: bool,
|
||||||
accepted: Option<OrchestratorAttentionDelivery>,
|
accepted: Option<OrchestratorAttentionDelivery>,
|
||||||
pending: Option<OrchestratorAttentionDelivery>,
|
pending: Option<OrchestratorAttentionDelivery>,
|
||||||
}
|
}
|
||||||
@@ -1547,10 +1547,11 @@ impl OrchestratorAttentionReservation {
|
|||||||
fn complete(mut self) -> bool {
|
fn complete(mut self) -> bool {
|
||||||
let state = self.0.take().expect("active attention reservation");
|
let state = self.0.take().expect("active attention reservation");
|
||||||
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
|
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
// Release and take deferred work atomically. A Restore arriving after
|
// Release and take deferred input checks atomically. A committed input or
|
||||||
// release can reserve its own check; one arriving before is ours to drain.
|
// Restore arriving before release is ours to drain; one after release
|
||||||
|
// can reserve its own check.
|
||||||
state.sending = false;
|
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
|
if orchestrator.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID
|
||||||
&& orchestrator.worker.worker_id == worker_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) {
|
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);
|
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(
|
fn with_orchestrator_attention_reservation(
|
||||||
api: &WorkspaceApi,
|
api: &WorkspaceApi,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
|
defer_input_check_if_reserved: bool,
|
||||||
mut check: impl FnMut(
|
mut check: impl FnMut(
|
||||||
&Arc<Mutex<OrchestratorAttentionState>>,
|
&Arc<Mutex<OrchestratorAttentionState>>,
|
||||||
Option<OrchestratorAttentionDelivery>,
|
Option<OrchestratorAttentionDelivery>,
|
||||||
@@ -18888,6 +18900,9 @@ fn with_orchestrator_attention_reservation(
|
|||||||
let pending = {
|
let pending = {
|
||||||
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
|
let mut state = state.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
if state.sending {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
state.sending = true;
|
state.sending = true;
|
||||||
@@ -18898,8 +18913,8 @@ fn with_orchestrator_attention_reservation(
|
|||||||
if !reservation.complete() {
|
if !reservation.complete() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Accepted Restore is an explicit durable-input recheck even when the
|
// Drain coalesced input triggers even when the first scan was a no-op.
|
||||||
// current check was a no-op. A local cache is never backlog authority.
|
// No recheck is created by a failed send or its own idle observation.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18951,10 +18966,19 @@ fn check_orchestrator_assignment_attention(
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
{
|
{
|
||||||
send_orchestrator_attention(api, worker, state, delivery);
|
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 {
|
} else {
|
||||||
state.lock().unwrap_or_else(|p| p.into_inner()).pending = None;
|
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
|
let Ok(inputs) = api
|
||||||
.store
|
.store
|
||||||
@@ -22969,11 +22993,6 @@ async fn scoped_start_workspace_orchestrator(
|
|||||||
{
|
{
|
||||||
attention.accepted = None;
|
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);
|
drop(_guard);
|
||||||
dispatch_orchestrator_assignment_attention(&api, &existing.worker);
|
dispatch_orchestrator_assignment_attention(&api, &existing.worker);
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ async fn orchestrator_attention_barrier_callers_share_unsent_reservation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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] {
|
for pause_before_comparison in [true, false] {
|
||||||
let (_dir, api, execution) = attention_test_api().await;
|
let (_dir, api, execution) = attention_test_api().await;
|
||||||
let started = start_attention_test_orchestrator(&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(
|
with_orchestrator_attention_reservation(
|
||||||
&api_for_caller,
|
&api_for_caller,
|
||||||
&worker_for_caller,
|
&worker_for_caller,
|
||||||
|
true,
|
||||||
|state, pending| {
|
|state, pending| {
|
||||||
if checks == 0 && pause_before_comparison {
|
if checks == 0 && pause_before_comparison {
|
||||||
entered_tx.send(()).unwrap();
|
entered_tx.send(()).unwrap();
|
||||||
@@ -224,7 +225,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_
|
|||||||
assert!(state.lock().unwrap().sending);
|
assert!(state.lock().unwrap().sending);
|
||||||
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
|
let restored = futures::executor::block_on(start_attention_test_orchestrator(&api));
|
||||||
assert_eq!(restored.disposition, "restored");
|
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 old_retired = state.lock().unwrap().accepted.is_none();
|
||||||
let inputs_before_release = execution.take_inputs();
|
let inputs_before_release = execution.take_inputs();
|
||||||
release_tx.send(()).unwrap();
|
release_tx.send(()).unwrap();
|
||||||
@@ -239,7 +240,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_
|
|||||||
let state = state.lock().unwrap();
|
let state = state.lock().unwrap();
|
||||||
assert!(state.accepted.is_none());
|
assert!(state.accepted.is_none());
|
||||||
assert!(!state.sending);
|
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"
|
"existing"
|
||||||
);
|
);
|
||||||
let retry = execution.take_inputs();
|
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!(retry[0].1, pending.content);
|
||||||
assert_eq!(
|
assert!(retry[1].1.contains("Changed backlog"));
|
||||||
execution.take_input_request_ids(),
|
let ids = execution.take_input_request_ids();
|
||||||
vec![Some(pending.request_id)]
|
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().pending.is_none());
|
||||||
assert!(state.lock().unwrap().accepted.is_some());
|
assert!(state.lock().unwrap().accepted.is_some());
|
||||||
|
assert!(attention_pending(&api).is_empty());
|
||||||
use protocol::subscription::SubscriptionWorkerState as S;
|
use protocol::subscription::SubscriptionWorkerState as S;
|
||||||
maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle);
|
maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle);
|
||||||
let changed = execution.take_inputs();
|
assert!(execution.take_inputs().is_empty());
|
||||||
assert_eq!(changed.len(), 1);
|
|
||||||
assert!(changed[0].1.contains("Changed backlog"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ async fn delivery_http(
|
|||||||
"{method} {path}: {}",
|
"{method} {path}: {}",
|
||||||
String::from_utf8_lossy(&bytes)
|
String::from_utf8_lossy(&bytes)
|
||||||
);
|
);
|
||||||
|
if bytes.is_empty() {
|
||||||
|
Value::Null
|
||||||
|
} else {
|
||||||
serde_json::from_slice(&bytes).unwrap()
|
serde_json::from_slice(&bytes).unwrap()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
|
async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value {
|
||||||
delivery_http(
|
delivery_http(
|
||||||
@@ -330,6 +334,7 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stopping_worker() {
|
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 dir = tempfile::tempdir().unwrap();
|
||||||
let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await;
|
let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await;
|
||||||
let ticket = delivery_ticket(&api, "Fence old delivery");
|
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();
|
let old_ids = execution.take_input_request_ids();
|
||||||
assert!(!old_attempts.is_empty());
|
assert!(!old_attempts.is_empty());
|
||||||
assert!(old_ids.iter().all(|id| id == &old_ids[0]));
|
assert!(old_ids.iter().all(|id| id == &old_ids[0]));
|
||||||
|
if clear_first {
|
||||||
delivery_http(
|
delivery_http(
|
||||||
&api,
|
&api,
|
||||||
"DELETE",
|
"DELETE",
|
||||||
@@ -351,38 +357,30 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop
|
|||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
}
|
||||||
*execution.input_failure.lock().unwrap() = None;
|
*execution.input_failure.lock().unwrap() = None;
|
||||||
notify_ticket_recipients(&api, TEST_WORKSPACE_ID, &ticket.id, "open", "open", None);
|
// No extra scan, old-operation retry, or synthetic turn end between
|
||||||
delivery_scan(&api, &worker);
|
// clearing/reissuing the failed input and this committed new Assign.
|
||||||
assert!(
|
let reassigned = delivery_assign(&api, &ticket.id, "assign-new").await;
|
||||||
execution.take_inputs().is_empty(),
|
let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||||
"cleared reservation was sent as new work"
|
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!(
|
assert_eq!(
|
||||||
delivery_assign(&api, &ticket.id, "assign-old").await,
|
delivery_assign(&api, &ticket.id, "assign-old").await,
|
||||||
assigned
|
assigned
|
||||||
);
|
);
|
||||||
delivery_scan(&api, &worker);
|
assert!(execution.take_inputs().is_empty(), "old retry revived work");
|
||||||
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"
|
|
||||||
);
|
|
||||||
let old = delivery_tracking(&api, &ticket.id, assignment);
|
let old = delivery_tracking(&api, &ticket.id, assignment);
|
||||||
assert!(!old.current);
|
assert!(!old.current);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
old.tracking.unwrap().status,
|
old.tracking.unwrap().status,
|
||||||
ticket::TicketAssignmentTrackingStatus::Waiting,
|
ticket::TicketAssignmentTrackingStatus::Waiting,
|
||||||
"clear must not rewrite the old status to done"
|
"clear/reissue must not rewrite the old status to done"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
find_workspace_orchestrator(&api).unwrap().worker,
|
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);
|
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, ¤t, "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,
|
// The model boundary is scripted, but Controller, notification acceptance,
|
||||||
// canonical session commits, Runtime management and HTTP routing are real.
|
// 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.
|
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.
|
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
|
## Public start/action handoff for T-743
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user