fix: separate worker state from runtime lifecycle

This commit is contained in:
2026-09-06 10:05:54 +09:00
parent 282a8d31b5
commit 2d1956b653
21 changed files with 602 additions and 188 deletions
+37 -16
View File
@@ -1180,18 +1180,14 @@ impl App {
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
self.run_requests += 1;
self.current_tool = None;
self.latest_llm_wait_event = None;
self.assistant_streaming = false;
}
Event::InvokeStart { .. } => {
self.set_worker_status(WorkerStatus::Running);
}
Event::InvokeStart { .. } => {}
// UI consumers of per-attempt LlmCall semantics remain out of scope;
// the run-level status starts at InvokeStart and TurnStart counts each
// LLM request within that run.
// authoritative run state comes only from WorkerStateSnapshot.
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
self.latest_llm_wait_event = None;
}
@@ -1398,12 +1394,7 @@ impl App {
output_tokens: self.run_output_tokens,
});
self.pending_submit_rollback = None;
self.reset_run_state(match result {
RunResult::Paused => WorkerStatus::Paused,
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
WorkerStatus::Idle
}
});
self.reset_run_state();
}
}
Event::CompactStart { .. } => {
@@ -1536,7 +1527,7 @@ impl App {
};
self.completion = None;
self.close_rewind_picker();
self.reset_run_state(self.worker_status);
self.reset_run_state();
let mut message = if restored_composer {
format!(
"Rewound session: discarded {} log entries; restored selected input to composer.",
@@ -1584,8 +1575,7 @@ impl App {
None
}
fn reset_run_state(&mut self, status: WorkerStatus) {
self.set_worker_status(status);
fn reset_run_state(&mut self) {
self.run_requests = 0;
self.run_upload_tokens = 0;
self.run_output_tokens = 0;
@@ -1615,7 +1605,7 @@ impl App {
"Rolled back empty assistant turn; no local submitted input was available to restore."
.to_owned()
};
self.reset_run_state(WorkerStatus::Idle);
self.reset_run_state();
self.blocks.push(Block::Alert {
level: AlertLevel::Warn,
source: AlertSource::Worker,
@@ -3583,6 +3573,37 @@ mod completion_flow_tests {
assert!(matches!(app.blocks.first(), Some(Block::Greeting(_))));
}
#[test]
fn occurrence_events_do_not_infer_foreground_worker_state() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::TurnStart { turn: 1 });
app.handle_worker_event(Event::InvokeStart {
kind: protocol::InvokeKind::UserSend,
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Paused,
});
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
assert_eq!(app.worker_status, WorkerStatus::Idle);
let running = WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
};
app.handle_worker_event(Event::WorkerState {
snapshot: running.clone(),
});
app.handle_worker_event(Event::RunEnd {
result: RunResult::Finished,
});
assert_eq!(app.worker_state, running);
assert_eq!(app.worker_status, WorkerStatus::Running);
}
#[test]
fn worker_state_events_and_acknowledgements_share_monotonic_application() {
let mut app = App::new("test".into());
+31 -3
View File
@@ -321,7 +321,7 @@ fn row_line(
Span::raw(" "),
Span::styled(
pad_column(&worker_state(worker), widths.state),
state_style(worker.state.as_str()),
state_style(worker_state_label(worker)),
),
Span::raw(" "),
Span::styled(
@@ -341,8 +341,20 @@ fn worker_name(worker: &BackendWorkerSummary) -> &str {
}
}
fn worker_state_label(worker: &BackendWorkerSummary) -> &str {
match worker.worker_state.as_ref().map(|state| &state.state) {
Some(protocol::WorkerState::Idle) => "idle",
Some(protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Paused,
))) => "paused",
Some(protocol::WorkerState::Busy(_)) => "running",
None if worker.state == "stopped" => "stopped",
None => "unknown",
}
}
fn worker_state(worker: &BackendWorkerSummary) -> String {
format!("[{}]", worker.state)
format!("[{}]", worker_state_label(worker))
}
fn text_width(value: &str) -> usize {
@@ -413,7 +425,15 @@ mod tests {
identity: "ws".to_string(),
workspace_id: Some("ws".to_string()),
},
state: "running".to_string(),
state: "idle".to_string(),
worker_state: Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 1,
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
)),
last_command_id: 0,
}),
last_seen_at: None,
pinned: false,
retention_state: String::new(),
@@ -450,6 +470,7 @@ mod tests {
worker.display_name = "Coder".to_string();
worker.label = "Coder · T-585".to_string();
worker.state = "stopped".to_string();
worker.worker_state = None;
worker.working_directory = Some(
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
@@ -478,12 +499,19 @@ mod tests {
short.label = "Coder".to_string();
short.display_name = short.label.clone();
short.state = "idle".to_string();
short.worker_state = Some(protocol::WorkerStateSnapshot {
execution_generation: 1,
revision: 2,
state: protocol::WorkerState::Idle,
last_command_id: 0,
});
let mut long = worker("runtime-a", "worker-b", None);
long.resource_key = "W-100".to_string();
long.label = "Longer worker · T-9".to_string();
long.display_name = long.label.clone();
long.state = "stopped".to_string();
long.worker_state = None;
for worker in [&mut short, &mut long] {
worker.working_directory = Some(