merge(runtime): cancel de background task immédiat et fiable depuis le workstate (#199, QA verte)
This commit is contained in:
@ -4447,8 +4447,7 @@ pub async fn spawn_background_command(
|
|||||||
|
|
||||||
/// `cancel_background_task` — request cancellation of a running background task.
|
/// `cancel_background_task` — request cancellation of a running background task.
|
||||||
///
|
///
|
||||||
/// The terminal `Cancelled` state is written by the completion sink; this returns
|
/// Returns the task as currently persisted after the cancel request.
|
||||||
/// the task as currently persisted (absent if it no longer exists).
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on
|
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id, `PROCESS`/`STORE` on
|
||||||
@ -4457,13 +4456,13 @@ pub async fn spawn_background_command(
|
|||||||
pub async fn cancel_background_task(
|
pub async fn cancel_background_task(
|
||||||
task_id: String,
|
task_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Option<BackgroundTaskDto>, ErrorDto> {
|
) -> Result<BackgroundTaskDto, ErrorDto> {
|
||||||
let id = parse_task_id(&task_id)?;
|
let id = parse_task_id(&task_id)?;
|
||||||
state
|
state
|
||||||
.cancel_background_task
|
.cancel_background_task
|
||||||
.execute(id)
|
.execute(id)
|
||||||
.await
|
.await
|
||||||
.map(|out| out.task.map(BackgroundTaskDto::from))
|
.map(|out| BackgroundTaskDto::from(out.task))
|
||||||
.map_err(ErrorDto::from)
|
.map_err(ErrorDto::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -209,33 +209,103 @@ impl SpawnBackgroundCommand {
|
|||||||
/// Output of [`CancelBackgroundTask`].
|
/// Output of [`CancelBackgroundTask`].
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct CancelBackgroundTaskOutput {
|
pub struct CancelBackgroundTaskOutput {
|
||||||
/// The task as currently persisted, if it still exists. The terminal
|
/// The task as currently persisted after the cancel request.
|
||||||
/// `Cancelled` state is written by the completion sink, not here.
|
pub task: BackgroundTask,
|
||||||
pub task: Option<BackgroundTask>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests cancellation of a running background task.
|
/// Requests cancellation of a running background task.
|
||||||
pub struct CancelBackgroundTask {
|
pub struct CancelBackgroundTask {
|
||||||
store: Arc<dyn BackgroundTaskStore>,
|
store: Arc<dyn BackgroundTaskStore>,
|
||||||
runner: Arc<dyn BackgroundTaskRunner>,
|
runner: Arc<dyn BackgroundTaskRunner>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
events: Option<Arc<dyn EventBus>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CancelBackgroundTask {
|
impl CancelBackgroundTask {
|
||||||
/// Wires the use case to its ports.
|
/// Wires the use case to its ports.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(store: Arc<dyn BackgroundTaskStore>, runner: Arc<dyn BackgroundTaskRunner>) -> Self {
|
pub fn new(
|
||||||
Self { store, runner }
|
store: Arc<dyn BackgroundTaskStore>,
|
||||||
|
runner: Arc<dyn BackgroundTaskRunner>,
|
||||||
|
clock: Arc<dyn Clock>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
store,
|
||||||
|
runner,
|
||||||
|
clock,
|
||||||
|
events: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signals the runner to cancel `task_id` (idempotent).
|
/// Attaches the public event stream publisher.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
|
||||||
|
self.events = Some(events);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Projects `Cancelling` immediately, then signals the runner to cancel `task_id`.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// [`AppError`] if the runner or store fails.
|
/// [`AppError`] if the task is missing, terminal, or the runner/store fails.
|
||||||
pub async fn execute(&self, task_id: TaskId) -> Result<CancelBackgroundTaskOutput, AppError> {
|
pub async fn execute(&self, task_id: TaskId) -> Result<CancelBackgroundTaskOutput, AppError> {
|
||||||
self.runner.cancel(task_id).await.map_err(map_port_err)?;
|
let task = self
|
||||||
let task = self.store.get(task_id).await.map_err(map_port_err)?;
|
.store
|
||||||
|
.get(task_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_port_err)?
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("background task {task_id}")))?;
|
||||||
|
if task.is_terminal() {
|
||||||
|
return Err(AppError::Invalid(format!(
|
||||||
|
"background task {task_id} is already terminal ({:?})",
|
||||||
|
task.state
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelling = if task.state == BackgroundTaskState::Cancelling {
|
||||||
|
task
|
||||||
|
} else {
|
||||||
|
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
|
||||||
|
let cancelling = task
|
||||||
|
.transition(BackgroundTaskState::Cancelling, now)
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.store.save(&cancelling).await.map_err(map_port_err)?;
|
||||||
|
self.publish_state_changed(&cancelling);
|
||||||
|
cancelling
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = self.runner.cancel(task_id).await {
|
||||||
|
let now = u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0);
|
||||||
|
let failed = cancelling
|
||||||
|
.complete(BackgroundTaskResult::CancelFailed {
|
||||||
|
finished_at_ms: now,
|
||||||
|
error: format!("failed to cancel background task: {err}"),
|
||||||
|
})
|
||||||
|
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||||
|
self.store.save(&failed).await.map_err(map_port_err)?;
|
||||||
|
self.publish_state_changed(&failed);
|
||||||
|
return Err(map_port_err(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = self
|
||||||
|
.store
|
||||||
|
.get(task_id)
|
||||||
|
.await
|
||||||
|
.map_err(map_port_err)?
|
||||||
|
.unwrap_or(cancelling);
|
||||||
Ok(CancelBackgroundTaskOutput { task })
|
Ok(CancelBackgroundTaskOutput { task })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn publish_state_changed(&self, task: &BackgroundTask) {
|
||||||
|
if let Some(events) = &self.events {
|
||||||
|
events.publish(DomainEvent::BackgroundTaskStateChanged {
|
||||||
|
project_id: task.project_id,
|
||||||
|
task_id: task.id,
|
||||||
|
owner_agent_id: task.owner_agent_id,
|
||||||
|
state: task.state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-only recall of the command invocation a background task was spawned with.
|
/// Read-only recall of the command invocation a background task was spawned with.
|
||||||
|
|||||||
@ -942,12 +942,14 @@ impl OrchestratorService {
|
|||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
rendezvous,
|
rendezvous,
|
||||||
},
|
},
|
||||||
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
|
BackgroundTaskResult::Failure { .. } | BackgroundTaskResult::CancelFailed { .. } => {
|
||||||
project_id: project.id,
|
DomainEvent::BackgroundTaskFailed {
|
||||||
task_id,
|
project_id: project.id,
|
||||||
owner_agent_id,
|
task_id,
|
||||||
rendezvous,
|
owner_agent_id,
|
||||||
},
|
rendezvous,
|
||||||
|
}
|
||||||
|
}
|
||||||
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
|
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
|
||||||
DomainEvent::BackgroundTaskCancelled {
|
DomainEvent::BackgroundTaskCancelled {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
@ -1333,9 +1335,11 @@ impl OrchestratorService {
|
|||||||
BackgroundTaskState::Queued => "Queued",
|
BackgroundTaskState::Queued => "Queued",
|
||||||
BackgroundTaskState::Running => "Running",
|
BackgroundTaskState::Running => "Running",
|
||||||
BackgroundTaskState::Waiting => "Waiting",
|
BackgroundTaskState::Waiting => "Waiting",
|
||||||
|
BackgroundTaskState::Cancelling => "Cancelling",
|
||||||
BackgroundTaskState::Completed => "Completed",
|
BackgroundTaskState::Completed => "Completed",
|
||||||
BackgroundTaskState::Failed => "Failed",
|
BackgroundTaskState::Failed => "Failed",
|
||||||
BackgroundTaskState::Cancelled => "Cancelled",
|
BackgroundTaskState::Cancelled => "Cancelled",
|
||||||
|
BackgroundTaskState::CancelFailed => "CancelFailed",
|
||||||
BackgroundTaskState::Expired => "Expired",
|
BackgroundTaskState::Expired => "Expired",
|
||||||
};
|
};
|
||||||
Ok(OrchestratorOutcome {
|
Ok(OrchestratorOutcome {
|
||||||
|
|||||||
@ -317,6 +317,10 @@ fn background_completion_prompt(task: &BackgroundTask) -> String {
|
|||||||
"La tâche de fond {} est terminée : annulée.\nRaison : {}",
|
"La tâche de fond {} est terminée : annulée.\nRaison : {}",
|
||||||
task.id, reason
|
task.id, reason
|
||||||
),
|
),
|
||||||
|
Some(BackgroundTaskResult::CancelFailed { error, .. }) => format!(
|
||||||
|
"La tâche de fond {} est terminée : échec de l'annulation.\nErreur : {}",
|
||||||
|
task.id, error
|
||||||
|
),
|
||||||
Some(BackgroundTaskResult::Expired { reason, .. }) => format!(
|
Some(BackgroundTaskResult::Expired { reason, .. }) => format!(
|
||||||
"La tâche de fond {} est terminée : expirée.\nRaison : {}",
|
"La tâche de fond {} est terminée : expirée.\nRaison : {}",
|
||||||
task.id, reason
|
task.id, reason
|
||||||
|
|||||||
@ -1940,9 +1940,11 @@ fn background_task_state_label(state: BackgroundTaskState) -> &'static str {
|
|||||||
BackgroundTaskState::Queued => "queued",
|
BackgroundTaskState::Queued => "queued",
|
||||||
BackgroundTaskState::Running => "running",
|
BackgroundTaskState::Running => "running",
|
||||||
BackgroundTaskState::Waiting => "waiting",
|
BackgroundTaskState::Waiting => "waiting",
|
||||||
|
BackgroundTaskState::Cancelling => "cancelling",
|
||||||
BackgroundTaskState::Completed => "completed",
|
BackgroundTaskState::Completed => "completed",
|
||||||
BackgroundTaskState::Failed => "failed",
|
BackgroundTaskState::Failed => "failed",
|
||||||
BackgroundTaskState::Cancelled => "cancelled",
|
BackgroundTaskState::Cancelled => "cancelled",
|
||||||
|
BackgroundTaskState::CancelFailed => "cancelFailed",
|
||||||
BackgroundTaskState::Expired => "expired",
|
BackgroundTaskState::Expired => "expired",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -689,6 +689,9 @@ fn flatten_background_result(
|
|||||||
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
||||||
(None, Some(reason.clone()), None, None)
|
(None, Some(reason.clone()), None, None)
|
||||||
}
|
}
|
||||||
|
Some(BackgroundTaskResult::CancelFailed { error, .. }) => {
|
||||||
|
(None, Some(error.clone()), None, None)
|
||||||
|
}
|
||||||
None => (None, None, None, None),
|
None => (None, None, None, None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
306
crates/application/tests/background_task_cancel.rs
Normal file
306
crates/application/tests/background_task_cancel.rs
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use application::CancelBackgroundTask;
|
||||||
|
use domain::ports::{
|
||||||
|
BackgroundCompletionStream, BackgroundTaskHandle, BackgroundTaskPortError,
|
||||||
|
BackgroundTaskRunner, BackgroundTaskSpec, BackgroundTaskStore, Clock, EventBus, EventStream,
|
||||||
|
};
|
||||||
|
use domain::{
|
||||||
|
AgentId, BackgroundTask, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskState,
|
||||||
|
BackgroundTaskWakePolicy, DomainEvent, ProjectId, TaskId,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn tid(n: u128) -> TaskId {
|
||||||
|
TaskId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid(n: u128) -> ProjectId {
|
||||||
|
ProjectId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn aid(n: u128) -> AgentId {
|
||||||
|
AgentId::from_uuid(Uuid::from_u128(n))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn running_task(id: TaskId) -> BackgroundTask {
|
||||||
|
BackgroundTask::new(
|
||||||
|
id,
|
||||||
|
pid(1),
|
||||||
|
aid(2),
|
||||||
|
BackgroundTaskKind::Command {
|
||||||
|
label: "build".to_owned(),
|
||||||
|
},
|
||||||
|
BackgroundTaskWakePolicy::WakeOwner,
|
||||||
|
100,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.transition(BackgroundTaskState::Running, 110)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FakeStore {
|
||||||
|
tasks: Mutex<HashMap<TaskId, BackgroundTask>>,
|
||||||
|
saves: Mutex<Vec<BackgroundTaskState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeStore {
|
||||||
|
fn insert(&self, task: BackgroundTask) {
|
||||||
|
self.tasks.lock().unwrap().insert(task.id, task);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn saved_states(&self) -> Vec<BackgroundTaskState> {
|
||||||
|
self.saves.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundTaskStore for FakeStore {
|
||||||
|
async fn create(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
self.insert(task.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, id: TaskId) -> Result<Option<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(self.tasks.lock().unwrap().get(&id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, task: &BackgroundTask) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
self.saves.lock().unwrap().push(task.state);
|
||||||
|
self.insert(task.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_open_for_agent(
|
||||||
|
&self,
|
||||||
|
agent_id: AgentId,
|
||||||
|
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(self
|
||||||
|
.tasks
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.values()
|
||||||
|
.filter(|task| task.owner_agent_id == agent_id && !task.is_terminal())
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_undelivered_completions(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<BackgroundTask>, BackgroundTaskPortError> {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_completion_delivered(
|
||||||
|
&self,
|
||||||
|
_task_id: TaskId,
|
||||||
|
) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FakeRunner {
|
||||||
|
result: Mutex<Result<(), BackgroundTaskPortError>>,
|
||||||
|
cancels: Mutex<Vec<TaskId>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeRunner {
|
||||||
|
fn ok() -> Self {
|
||||||
|
Self {
|
||||||
|
result: Mutex::new(Ok(())),
|
||||||
|
cancels: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failing(message: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
result: Mutex::new(Err(BackgroundTaskPortError::Runner(message.to_owned()))),
|
||||||
|
cancels: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancels(&self) -> Vec<TaskId> {
|
||||||
|
self.cancels.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BackgroundTaskRunner for FakeRunner {
|
||||||
|
async fn spawn(
|
||||||
|
&self,
|
||||||
|
spec: BackgroundTaskSpec,
|
||||||
|
) -> Result<BackgroundTaskHandle, BackgroundTaskPortError> {
|
||||||
|
Ok(BackgroundTaskHandle {
|
||||||
|
task_id: spec.task_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel(&self, task_id: TaskId) -> Result<(), BackgroundTaskPortError> {
|
||||||
|
self.cancels.lock().unwrap().push(task_id);
|
||||||
|
self.result.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe_completions(&self) -> BackgroundCompletionStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct SpyEvents {
|
||||||
|
events: Mutex<Vec<DomainEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpyEvents {
|
||||||
|
fn events(&self) -> Vec<DomainEvent> {
|
||||||
|
self.events.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventBus for SpyEvents {
|
||||||
|
fn publish(&self, event: DomainEvent) {
|
||||||
|
self.events.lock().unwrap().push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe(&self) -> EventStream {
|
||||||
|
Box::new(std::iter::empty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FixedClock(i64);
|
||||||
|
|
||||||
|
impl Clock for FixedClock {
|
||||||
|
fn now_millis(&self) -> i64 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usecase(
|
||||||
|
store: Arc<FakeStore>,
|
||||||
|
runner: Arc<FakeRunner>,
|
||||||
|
events: Arc<SpyEvents>,
|
||||||
|
) -> CancelBackgroundTask {
|
||||||
|
CancelBackgroundTask::new(store, runner, Arc::new(FixedClock(200))).with_events(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_running_projects_cancelling_immediately_then_calls_runner() {
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
let runner = Arc::new(FakeRunner::ok());
|
||||||
|
let events = Arc::new(SpyEvents::default());
|
||||||
|
store.insert(running_task(tid(10)));
|
||||||
|
|
||||||
|
let out = usecase(Arc::clone(&store), Arc::clone(&runner), Arc::clone(&events))
|
||||||
|
.execute(tid(10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.task.state, BackgroundTaskState::Cancelling);
|
||||||
|
assert_eq!(store.saved_states(), vec![BackgroundTaskState::Cancelling]);
|
||||||
|
assert_eq!(runner.cancels(), vec![tid(10)]);
|
||||||
|
assert_eq!(
|
||||||
|
events.events(),
|
||||||
|
vec![DomainEvent::BackgroundTaskStateChanged {
|
||||||
|
project_id: pid(1),
|
||||||
|
task_id: tid(10),
|
||||||
|
owner_agent_id: aid(2),
|
||||||
|
state: BackgroundTaskState::Cancelling,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_terminal_task_is_explicit_error_without_runner_call() {
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
let runner = Arc::new(FakeRunner::ok());
|
||||||
|
let events = Arc::new(SpyEvents::default());
|
||||||
|
let done = running_task(tid(10))
|
||||||
|
.complete(BackgroundTaskResult::Success {
|
||||||
|
finished_at_ms: 120,
|
||||||
|
exit_code: Some(0),
|
||||||
|
summary: "ok".to_owned(),
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
store.insert(done);
|
||||||
|
|
||||||
|
let err = usecase(Arc::clone(&store), Arc::clone(&runner), events)
|
||||||
|
.execute(tid(10))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, application::AppError::Invalid(_)));
|
||||||
|
assert!(runner.cancels().is_empty());
|
||||||
|
assert!(store.saved_states().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_already_cancelling_is_idempotent_and_retries_runner_cancel() {
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
let runner = Arc::new(FakeRunner::ok());
|
||||||
|
let events = Arc::new(SpyEvents::default());
|
||||||
|
store.insert(
|
||||||
|
running_task(tid(10))
|
||||||
|
.transition(BackgroundTaskState::Cancelling, 120)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let out = usecase(Arc::clone(&store), Arc::clone(&runner), events)
|
||||||
|
.execute(tid(10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(out.task.state, BackgroundTaskState::Cancelling);
|
||||||
|
assert_eq!(runner.cancels(), vec![tid(10)]);
|
||||||
|
assert!(store.saved_states().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_kill_failure_persists_cancel_failed_and_returns_error() {
|
||||||
|
let store = Arc::new(FakeStore::default());
|
||||||
|
let runner = Arc::new(FakeRunner::failing("kill denied"));
|
||||||
|
let events = Arc::new(SpyEvents::default());
|
||||||
|
store.insert(running_task(tid(10)));
|
||||||
|
|
||||||
|
let err = usecase(Arc::clone(&store), Arc::clone(&runner), Arc::clone(&events))
|
||||||
|
.execute(tid(10))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(err, application::AppError::Process(_)));
|
||||||
|
assert_eq!(
|
||||||
|
store.saved_states(),
|
||||||
|
vec![
|
||||||
|
BackgroundTaskState::Cancelling,
|
||||||
|
BackgroundTaskState::CancelFailed,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let persisted = store.get(tid(10)).await.unwrap().unwrap();
|
||||||
|
assert_eq!(persisted.state, BackgroundTaskState::CancelFailed);
|
||||||
|
assert!(matches!(
|
||||||
|
persisted.result,
|
||||||
|
Some(BackgroundTaskResult::CancelFailed { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
events.events(),
|
||||||
|
vec![
|
||||||
|
DomainEvent::BackgroundTaskStateChanged {
|
||||||
|
project_id: pid(1),
|
||||||
|
task_id: tid(10),
|
||||||
|
owner_agent_id: aid(2),
|
||||||
|
state: BackgroundTaskState::Cancelling,
|
||||||
|
},
|
||||||
|
DomainEvent::BackgroundTaskStateChanged {
|
||||||
|
project_id: pid(1),
|
||||||
|
task_id: tid(10),
|
||||||
|
owner_agent_id: aid(2),
|
||||||
|
state: BackgroundTaskState::CancelFailed,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -568,9 +568,15 @@ fn background_task(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.transition(BackgroundTaskState::Waiting, created_at_ms + 20)
|
.transition(BackgroundTaskState::Waiting, created_at_ms + 20)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
BackgroundTaskState::Cancelling => base
|
||||||
|
.transition(BackgroundTaskState::Running, created_at_ms + 10)
|
||||||
|
.unwrap()
|
||||||
|
.transition(BackgroundTaskState::Cancelling, created_at_ms + 20)
|
||||||
|
.unwrap(),
|
||||||
BackgroundTaskState::Completed
|
BackgroundTaskState::Completed
|
||||||
| BackgroundTaskState::Failed
|
| BackgroundTaskState::Failed
|
||||||
| BackgroundTaskState::Cancelled
|
| BackgroundTaskState::Cancelled
|
||||||
|
| BackgroundTaskState::CancelFailed
|
||||||
| BackgroundTaskState::Expired => {
|
| BackgroundTaskState::Expired => {
|
||||||
let result = match state {
|
let result = match state {
|
||||||
BackgroundTaskState::Completed => BackgroundTaskResult::Success {
|
BackgroundTaskState::Completed => BackgroundTaskResult::Success {
|
||||||
@ -591,13 +597,18 @@ fn background_task(
|
|||||||
finished_at_ms: created_at_ms + 20,
|
finished_at_ms: created_at_ms + 20,
|
||||||
reason: "cancelled".to_owned(),
|
reason: "cancelled".to_owned(),
|
||||||
},
|
},
|
||||||
|
BackgroundTaskState::CancelFailed => BackgroundTaskResult::CancelFailed {
|
||||||
|
finished_at_ms: created_at_ms + 20,
|
||||||
|
error: "cancel failed".to_owned(),
|
||||||
|
},
|
||||||
BackgroundTaskState::Expired => BackgroundTaskResult::Expired {
|
BackgroundTaskState::Expired => BackgroundTaskResult::Expired {
|
||||||
finished_at_ms: created_at_ms + 20,
|
finished_at_ms: created_at_ms + 20,
|
||||||
reason: "expired".to_owned(),
|
reason: "expired".to_owned(),
|
||||||
},
|
},
|
||||||
BackgroundTaskState::Queued
|
BackgroundTaskState::Queued
|
||||||
| BackgroundTaskState::Running
|
| BackgroundTaskState::Running
|
||||||
| BackgroundTaskState::Waiting => unreachable!(),
|
| BackgroundTaskState::Waiting
|
||||||
|
| BackgroundTaskState::Cancelling => unreachable!(),
|
||||||
};
|
};
|
||||||
let running = base
|
let running = base
|
||||||
.transition(BackgroundTaskState::Running, created_at_ms + 10)
|
.transition(BackgroundTaskState::Running, created_at_ms + 10)
|
||||||
|
|||||||
@ -5161,9 +5161,11 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str {
|
|||||||
BackgroundTaskState::Queued => "queued",
|
BackgroundTaskState::Queued => "queued",
|
||||||
BackgroundTaskState::Running => "running",
|
BackgroundTaskState::Running => "running",
|
||||||
BackgroundTaskState::Waiting => "waiting",
|
BackgroundTaskState::Waiting => "waiting",
|
||||||
|
BackgroundTaskState::Cancelling => "cancelling",
|
||||||
BackgroundTaskState::Completed => "completed",
|
BackgroundTaskState::Completed => "completed",
|
||||||
BackgroundTaskState::Failed => "failed",
|
BackgroundTaskState::Failed => "failed",
|
||||||
BackgroundTaskState::Cancelled => "cancelled",
|
BackgroundTaskState::Cancelled => "cancelled",
|
||||||
|
BackgroundTaskState::CancelFailed => "cancelFailed",
|
||||||
BackgroundTaskState::Expired => "expired",
|
BackgroundTaskState::Expired => "expired",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -5201,6 +5203,9 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
|||||||
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => {
|
||||||
(None, Some(reason.clone()), None, None)
|
(None, Some(reason.clone()), None, None)
|
||||||
}
|
}
|
||||||
|
Some(BackgroundTaskResult::CancelFailed { error, .. }) => {
|
||||||
|
(None, Some(error.clone()), None, None)
|
||||||
|
}
|
||||||
None => (None, None, None, None),
|
None => (None, None, None, None),
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@ -1357,6 +1357,7 @@ fn background_task_tail_bytes(task: &BackgroundTask) -> Vec<u8> {
|
|||||||
.into_bytes(),
|
.into_bytes(),
|
||||||
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
Some(BackgroundTaskResult::Cancelled { reason, .. })
|
||||||
| Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
|
| Some(BackgroundTaskResult::Expired { reason, .. }) => reason.clone().into_bytes(),
|
||||||
|
Some(BackgroundTaskResult::CancelFailed { error, .. }) => error.clone().into_bytes(),
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2452,10 +2453,14 @@ impl BackendCore {
|
|||||||
)
|
)
|
||||||
.with_events(Arc::clone(&events_port)),
|
.with_events(Arc::clone(&events_port)),
|
||||||
);
|
);
|
||||||
let cancel_background_task = Arc::new(CancelBackgroundTask::new(
|
let cancel_background_task = Arc::new(
|
||||||
Arc::clone(&background_tasks_port),
|
CancelBackgroundTask::new(
|
||||||
Arc::clone(&background_runner_port),
|
Arc::clone(&background_tasks_port),
|
||||||
));
|
Arc::clone(&background_runner_port),
|
||||||
|
Arc::clone(&clock) as Arc<dyn Clock>,
|
||||||
|
)
|
||||||
|
.with_events(Arc::clone(&events_port)),
|
||||||
|
);
|
||||||
let retry_background_task = Arc::new(RetryBackgroundTask::new(
|
let retry_background_task = Arc::new(RetryBackgroundTask::new(
|
||||||
Arc::clone(&background_tasks_port),
|
Arc::clone(&background_tasks_port),
|
||||||
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
Arc::clone(&background_runner) as Arc<dyn BackgroundCommandArchive>,
|
||||||
|
|||||||
@ -28,12 +28,16 @@ pub enum BackgroundTaskState {
|
|||||||
Running,
|
Running,
|
||||||
/// Suspended while waiting for an external event.
|
/// Suspended while waiting for an external event.
|
||||||
Waiting,
|
Waiting,
|
||||||
|
/// Cancellation has been accepted and the runner is stopping the resource.
|
||||||
|
Cancelling,
|
||||||
/// Finished successfully.
|
/// Finished successfully.
|
||||||
Completed,
|
Completed,
|
||||||
/// Finished with an error.
|
/// Finished with an error.
|
||||||
Failed,
|
Failed,
|
||||||
/// Cancelled before natural completion.
|
/// Cancelled before natural completion.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
|
/// Cancellation was requested but the runner could not stop the resource.
|
||||||
|
CancelFailed,
|
||||||
/// Expired after its deadline.
|
/// Expired after its deadline.
|
||||||
Expired,
|
Expired,
|
||||||
}
|
}
|
||||||
@ -44,7 +48,7 @@ impl BackgroundTaskState {
|
|||||||
pub const fn is_terminal(self) -> bool {
|
pub const fn is_terminal(self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
Self::Completed | Self::Failed | Self::Cancelled | Self::Expired
|
Self::Completed | Self::Failed | Self::Cancelled | Self::CancelFailed | Self::Expired
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -342,6 +346,13 @@ pub enum BackgroundTaskResult {
|
|||||||
/// Cancellation reason.
|
/// Cancellation reason.
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
/// Cancellation failed.
|
||||||
|
CancelFailed {
|
||||||
|
/// Completion timestamp, epoch milliseconds.
|
||||||
|
finished_at_ms: u64,
|
||||||
|
/// Human-readable error.
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
/// Expired completion.
|
/// Expired completion.
|
||||||
Expired {
|
Expired {
|
||||||
/// Completion timestamp, epoch milliseconds.
|
/// Completion timestamp, epoch milliseconds.
|
||||||
@ -375,6 +386,7 @@ impl BackgroundTaskResult {
|
|||||||
Self::Success { finished_at_ms, .. }
|
Self::Success { finished_at_ms, .. }
|
||||||
| Self::Failure { finished_at_ms, .. }
|
| Self::Failure { finished_at_ms, .. }
|
||||||
| Self::Cancelled { finished_at_ms, .. }
|
| Self::Cancelled { finished_at_ms, .. }
|
||||||
|
| Self::CancelFailed { finished_at_ms, .. }
|
||||||
| Self::Expired { finished_at_ms, .. } => *finished_at_ms,
|
| Self::Expired { finished_at_ms, .. } => *finished_at_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -408,6 +420,9 @@ impl BackgroundTaskResult {
|
|||||||
Self::Cancelled { reason, .. } | Self::Expired { reason, .. } => {
|
Self::Cancelled { reason, .. } | Self::Expired { reason, .. } => {
|
||||||
validate_text("reason", reason)?;
|
validate_text("reason", reason)?;
|
||||||
}
|
}
|
||||||
|
Self::CancelFailed { error, .. } => {
|
||||||
|
validate_text("error", error)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -417,6 +432,7 @@ impl BackgroundTaskResult {
|
|||||||
Self::Success { .. } => BackgroundTaskState::Completed,
|
Self::Success { .. } => BackgroundTaskState::Completed,
|
||||||
Self::Failure { .. } => BackgroundTaskState::Failed,
|
Self::Failure { .. } => BackgroundTaskState::Failed,
|
||||||
Self::Cancelled { .. } => BackgroundTaskState::Cancelled,
|
Self::Cancelled { .. } => BackgroundTaskState::Cancelled,
|
||||||
|
Self::CancelFailed { .. } => BackgroundTaskState::CancelFailed,
|
||||||
Self::Expired { .. } => BackgroundTaskState::Expired,
|
Self::Expired { .. } => BackgroundTaskState::Expired,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -459,13 +475,22 @@ pub enum BackgroundTaskError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_transition_allowed(from: BackgroundTaskState, to: BackgroundTaskState) -> bool {
|
fn is_transition_allowed(from: BackgroundTaskState, to: BackgroundTaskState) -> bool {
|
||||||
use BackgroundTaskState::{Cancelled, Completed, Expired, Failed, Queued, Running, Waiting};
|
use BackgroundTaskState::{
|
||||||
|
CancelFailed, Cancelled, Cancelling, Completed, Expired, Failed, Queued, Running, Waiting,
|
||||||
|
};
|
||||||
|
|
||||||
matches!(
|
matches!(
|
||||||
(from, to),
|
(from, to),
|
||||||
(Queued, Running | Cancelled | Expired)
|
(
|
||||||
| (Running, Waiting | Completed | Failed | Cancelled | Expired)
|
Queued,
|
||||||
| (Waiting, Running | Completed | Failed | Cancelled | Expired)
|
Running | Cancelling | Cancelled | CancelFailed | Expired
|
||||||
|
) | (
|
||||||
|
Running,
|
||||||
|
Waiting | Cancelling | Completed | Failed | Cancelled | CancelFailed | Expired
|
||||||
|
) | (
|
||||||
|
Waiting,
|
||||||
|
Running | Cancelling | Completed | Failed | Cancelled | CancelFailed | Expired
|
||||||
|
) | (Cancelling, Cancelled | CancelFailed | Expired)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -134,6 +134,11 @@ fn allowed_non_terminal_transitions_update_timestamp() {
|
|||||||
.transition(BackgroundTaskState::Running, 140)
|
.transition(BackgroundTaskState::Running, 140)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(running_again.state, BackgroundTaskState::Running);
|
assert_eq!(running_again.state, BackgroundTaskState::Running);
|
||||||
|
|
||||||
|
let cancelling = running_again
|
||||||
|
.transition(BackgroundTaskState::Cancelling, 150)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cancelling.state, BackgroundTaskState::Cancelling);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -245,6 +250,18 @@ fn result_variants_determine_terminal_state() {
|
|||||||
.state,
|
.state,
|
||||||
BackgroundTaskState::Expired
|
BackgroundTaskState::Expired
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
running
|
||||||
|
.transition(BackgroundTaskState::Cancelling, 125)
|
||||||
|
.unwrap()
|
||||||
|
.complete(BackgroundTaskResult::CancelFailed {
|
||||||
|
finished_at_ms: 130,
|
||||||
|
error: "kill failed".into(),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.state,
|
||||||
|
BackgroundTaskState::CancelFailed
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@ -139,11 +139,14 @@ impl CommandBackgroundRunner {
|
|||||||
.filter(|s| !s.is_empty());
|
.filter(|s| !s.is_empty());
|
||||||
|
|
||||||
// Reap the real exit status (works for a natural exit and a forced kill).
|
// Reap the real exit status (works for a natural exit and a forced kill).
|
||||||
let exit_code = pty
|
let exit_code = match outcome {
|
||||||
.kill(&control.pty_handle)
|
WaitOutcome::Cancelled => None,
|
||||||
.await
|
WaitOutcome::Expired | WaitOutcome::Exited => pty
|
||||||
.ok()
|
.kill(&control.pty_handle)
|
||||||
.and_then(|status| status.code);
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(|status| status.code),
|
||||||
|
};
|
||||||
|
|
||||||
let finished_at_ms = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
|
let finished_at_ms = u64::try_from(clock.now_millis().max(0)).unwrap_or(0);
|
||||||
let result = match outcome {
|
let result = match outcome {
|
||||||
@ -281,8 +284,12 @@ impl BackgroundTaskRunner for CommandBackgroundRunner {
|
|||||||
.cloned();
|
.cloned();
|
||||||
// Unknown task ⇒ already terminal or never ours: cancellation is idempotent.
|
// Unknown task ⇒ already terminal or never ours: cancellation is idempotent.
|
||||||
if let Some(control) = control {
|
if let Some(control) = control {
|
||||||
|
self.pty
|
||||||
|
.kill(&control.pty_handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| BackgroundTaskPortError::Runner(format!("pty kill failed: {e}")))?;
|
||||||
control.cancelled.store(true, Ordering::SeqCst);
|
control.cancelled.store(true, Ordering::SeqCst);
|
||||||
control.cancel.notify_one();
|
control.cancel.notify_waiters();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -108,7 +108,8 @@ impl FsBackgroundTaskStore {
|
|||||||
/// Reconciles persisted tasks against live runtime handles at boot.
|
/// Reconciles persisted tasks against live runtime handles at boot.
|
||||||
///
|
///
|
||||||
/// B2 rule: `Running`/`Waiting` tasks missing from `live_task_ids` are marked
|
/// B2 rule: `Running`/`Waiting` tasks missing from `live_task_ids` are marked
|
||||||
/// `Failed` with a synthetic restart-loss failure and become delivery-pending.
|
/// `Failed`; `Cancelling` tasks missing from `live_task_ids` are marked
|
||||||
|
/// `CancelFailed`. Both become delivery-pending.
|
||||||
/// Already-terminal undelivered tasks are reported as delivery-pending. `Queued`
|
/// Already-terminal undelivered tasks are reported as delivery-pending. `Queued`
|
||||||
/// tasks are left open.
|
/// tasks are left open.
|
||||||
///
|
///
|
||||||
@ -127,18 +128,13 @@ impl FsBackgroundTaskStore {
|
|||||||
for (project_id, doc) in &mut state.docs {
|
for (project_id, doc) in &mut state.docs {
|
||||||
for task in &mut doc.tasks {
|
for task in &mut doc.tasks {
|
||||||
match task.state {
|
match task.state {
|
||||||
BackgroundTaskState::Running | BackgroundTaskState::Waiting
|
BackgroundTaskState::Running
|
||||||
|
| BackgroundTaskState::Waiting
|
||||||
|
| BackgroundTaskState::Cancelling
|
||||||
if !live.contains(&task.id) =>
|
if !live.contains(&task.id) =>
|
||||||
{
|
{
|
||||||
let finished_at_ms = now_ms.max(task.updated_at_ms);
|
let finished_at_ms = now_ms.max(task.updated_at_ms);
|
||||||
let result = BackgroundTaskResult::Failure {
|
let result = lost_runtime_result(task.state, finished_at_ms);
|
||||||
finished_at_ms,
|
|
||||||
exit_code: None,
|
|
||||||
error: "background task lost its runtime handle during IdeA restart"
|
|
||||||
.into(),
|
|
||||||
stdout_tail: None,
|
|
||||||
stderr_tail: None,
|
|
||||||
};
|
|
||||||
*task = task.complete(result).map_err(invalid_task)?;
|
*task = task.complete(result).map_err(invalid_task)?;
|
||||||
report.failed_task_ids.push(task.id);
|
report.failed_task_ids.push(task.id);
|
||||||
report.delivery_pending_task_ids.push(task.id);
|
report.delivery_pending_task_ids.push(task.id);
|
||||||
@ -158,18 +154,13 @@ impl FsBackgroundTaskStore {
|
|||||||
for task in &mut doc.tasks {
|
for task in &mut doc.tasks {
|
||||||
if matches!(
|
if matches!(
|
||||||
task.state,
|
task.state,
|
||||||
BackgroundTaskState::Running | BackgroundTaskState::Waiting
|
BackgroundTaskState::Running
|
||||||
|
| BackgroundTaskState::Waiting
|
||||||
|
| BackgroundTaskState::Cancelling
|
||||||
) && !live.contains(&task.id)
|
) && !live.contains(&task.id)
|
||||||
{
|
{
|
||||||
let finished_at_ms = now_ms.max(task.updated_at_ms);
|
let finished_at_ms = now_ms.max(task.updated_at_ms);
|
||||||
let result = BackgroundTaskResult::Failure {
|
let result = lost_runtime_result(task.state, finished_at_ms);
|
||||||
finished_at_ms,
|
|
||||||
exit_code: None,
|
|
||||||
error: "background task lost its runtime handle during IdeA restart"
|
|
||||||
.into(),
|
|
||||||
stdout_tail: None,
|
|
||||||
stderr_tail: None,
|
|
||||||
};
|
|
||||||
*task = task.complete(result).map_err(invalid_task)?;
|
*task = task.complete(result).map_err(invalid_task)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -444,3 +435,21 @@ fn io_error(error: std::io::Error) -> BackgroundTaskPortError {
|
|||||||
fn invalid_task(error: domain::BackgroundTaskError) -> BackgroundTaskPortError {
|
fn invalid_task(error: domain::BackgroundTaskError) -> BackgroundTaskPortError {
|
||||||
BackgroundTaskPortError::Invalid(error.to_string())
|
BackgroundTaskPortError::Invalid(error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn lost_runtime_result(state: BackgroundTaskState, finished_at_ms: u64) -> BackgroundTaskResult {
|
||||||
|
if state == BackgroundTaskState::Cancelling {
|
||||||
|
BackgroundTaskResult::CancelFailed {
|
||||||
|
finished_at_ms,
|
||||||
|
error: "background task cancellation lost its runtime handle during IdeA restart"
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BackgroundTaskResult::Failure {
|
||||||
|
finished_at_ms,
|
||||||
|
exit_code: None,
|
||||||
|
error: "background task lost its runtime handle during IdeA restart".into(),
|
||||||
|
stdout_tail: None,
|
||||||
|
stderr_tail: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ struct FakePtyState {
|
|||||||
handle: Option<PtyHandle>,
|
handle: Option<PtyHandle>,
|
||||||
status: Option<ExitStatus>,
|
status: Option<ExitStatus>,
|
||||||
kills: usize,
|
kills: usize,
|
||||||
|
fail_kill: bool,
|
||||||
scrollback: Vec<u8>,
|
scrollback: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,6 +58,10 @@ impl FakePty {
|
|||||||
self.state.lock().unwrap().kills
|
self.state.lock().unwrap().kills
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fail_kill(&self) {
|
||||||
|
self.state.lock().unwrap().fail_kill = true;
|
||||||
|
}
|
||||||
|
|
||||||
fn subscribe_count(&self) -> usize {
|
fn subscribe_count(&self) -> usize {
|
||||||
self.subscribe_count.load(Ordering::SeqCst)
|
self.subscribe_count.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
@ -105,6 +110,9 @@ impl PtyPort for FakePty {
|
|||||||
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
||||||
let mut state = self.state.lock().unwrap();
|
let mut state = self.state.lock().unwrap();
|
||||||
state.kills += 1;
|
state.kills += 1;
|
||||||
|
if state.fail_kill {
|
||||||
|
return Err(PtyError::Io("kill denied".to_owned()));
|
||||||
|
}
|
||||||
let status = state.status.get_or_insert(ExitStatus { code: Some(130) });
|
let status = state.status.get_or_insert(ExitStatus { code: Some(130) });
|
||||||
Ok(*status)
|
Ok(*status)
|
||||||
}
|
}
|
||||||
@ -314,6 +322,31 @@ async fn deadline_expires_and_kills_when_wait_never_resolves() {
|
|||||||
assert_eq!(pty.subscribe_count(), 0);
|
assert_eq!(pty.subscribe_count(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancel_surfaces_pty_kill_failure_without_completion() {
|
||||||
|
let pty = Arc::new(FakePty::default());
|
||||||
|
pty.fail_kill();
|
||||||
|
let clock = Arc::new(FakeClock::default());
|
||||||
|
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
|
||||||
|
let task_id = TaskId::from_uuid(id(16));
|
||||||
|
|
||||||
|
runner
|
||||||
|
.spawn(spawn_spec(task_id, None))
|
||||||
|
.await
|
||||||
|
.expect("spawn succeeds");
|
||||||
|
|
||||||
|
let err = runner
|
||||||
|
.cancel(task_id)
|
||||||
|
.await
|
||||||
|
.expect_err("kill failure surfaces");
|
||||||
|
assert!(matches!(err, domain::BackgroundTaskPortError::Runner(_)));
|
||||||
|
assert_eq!(pty.kill_count(), 1);
|
||||||
|
assert!(
|
||||||
|
runner.pty_handle_for(task_id).is_some(),
|
||||||
|
"failed cancel keeps the live handle available for diagnostics/retry"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cancel_wins_even_if_output_is_still_available() {
|
async fn cancel_wins_even_if_output_is_still_available() {
|
||||||
let pty = Arc::new(FakePty::default());
|
let pty = Arc::new(FakePty::default());
|
||||||
|
|||||||
@ -86,6 +86,12 @@ fn running_task(id: u128, project: u128, owner: u128, now_ms: u64) -> Background
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cancelling_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
|
||||||
|
running_task(id, project, owner, now_ms)
|
||||||
|
.transition(BackgroundTaskState::Cancelling, now_ms + 2)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
fn completed_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
|
fn completed_task(id: u128, project: u128, owner: u128, now_ms: u64) -> BackgroundTask {
|
||||||
running_task(id, project, owner, now_ms)
|
running_task(id, project, owner, now_ms)
|
||||||
.complete(BackgroundTaskResult::Success {
|
.complete(BackgroundTaskResult::Success {
|
||||||
@ -337,6 +343,31 @@ async fn reconcile_marks_running_without_live_handle_failed_and_pending() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reconcile_marks_cancelling_without_live_handle_cancel_failed_and_pending() {
|
||||||
|
let tmp = TempDir::new();
|
||||||
|
let store = FsBackgroundTaskStore::new(&tmp.project_path());
|
||||||
|
let lost = cancelling_task(1, 10, 100, 1_000);
|
||||||
|
|
||||||
|
store.create(&lost).await.unwrap();
|
||||||
|
|
||||||
|
let report = store.reconcile_boot(&[], 2_000).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.failed_task_ids, vec![lost.id]);
|
||||||
|
assert_eq!(report.delivery_pending_task_ids, vec![lost.id]);
|
||||||
|
|
||||||
|
let lost_after = store.get(lost.id).await.unwrap().unwrap();
|
||||||
|
assert_eq!(lost_after.state, BackgroundTaskState::CancelFailed);
|
||||||
|
assert!(lost_after.has_pending_completion_delivery());
|
||||||
|
assert!(matches!(
|
||||||
|
lost_after.result,
|
||||||
|
Some(BackgroundTaskResult::CancelFailed {
|
||||||
|
finished_at_ms: 2_000,
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reconcile_uses_monotonic_timestamp_when_clock_is_behind() {
|
async fn reconcile_uses_monotonic_timestamp_when_clock_is_behind() {
|
||||||
let tmp = TempDir::new();
|
let tmp = TempDir::new();
|
||||||
|
|||||||
@ -2955,7 +2955,7 @@ async fn invoke_cancel_background_task(
|
|||||||
.execute(task_id)
|
.execute(task_id)
|
||||||
.await
|
.await
|
||||||
.map_err(ErrorDto::from)?;
|
.map_err(ErrorDto::from)?;
|
||||||
serde_json::to_value(output.task.map(BackgroundTaskDto::from)).map_err(serialization_error)
|
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn invoke_retry_background_task(
|
async fn invoke_retry_background_task(
|
||||||
|
|||||||
@ -86,7 +86,11 @@ function normalizeBackgroundStatus(
|
|||||||
const raw = typeof value === "string" ? value.toLowerCase() : "";
|
const raw = typeof value === "string" ? value.toLowerCase() : "";
|
||||||
if (raw === "completed" || raw === "success" || raw === "succeeded") return "completed";
|
if (raw === "completed" || raw === "success" || raw === "succeeded") return "completed";
|
||||||
if (raw === "failed" || raw === "failure") return "failed";
|
if (raw === "failed" || raw === "failure") return "failed";
|
||||||
|
if (raw === "cancelfailed" || raw === "cancel_failed" || raw === "cancel-failed") {
|
||||||
|
return "cancelFailed";
|
||||||
|
}
|
||||||
if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled";
|
if (raw === "cancelled" || raw === "canceled" || raw === "expired") return "cancelled";
|
||||||
|
if (raw === "cancelling" || raw === "canceling") return "cancelling";
|
||||||
if (raw === "running" || raw === "started") return "running";
|
if (raw === "running" || raw === "started") return "running";
|
||||||
if (raw === "delivered") return "delivered";
|
if (raw === "delivered") return "delivered";
|
||||||
// Backend `queued`/`waiting` collapse to the cancellable "pending" bucket.
|
// Backend `queued`/`waiting` collapse to the cancellable "pending" bucket.
|
||||||
|
|||||||
@ -612,7 +612,15 @@ export interface BackgroundCompletion {
|
|||||||
ownerAgentId: string;
|
ownerAgentId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
status: "running" | "completed" | "failed" | "cancelled" | "pending" | "delivered";
|
status:
|
||||||
|
| "running"
|
||||||
|
| "cancelling"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelFailed"
|
||||||
|
| "cancelled"
|
||||||
|
| "pending"
|
||||||
|
| "delivered";
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
/** Human-readable summary / error / reason of a terminal result (ticket #5). */
|
/** Human-readable summary / error / reason of a terminal result (ticket #5). */
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
|
|||||||
export type ProjectActivityStatus = "none" | "idle" | "busy";
|
export type ProjectActivityStatus = "none" | "idle" | "busy";
|
||||||
|
|
||||||
const ACTIVE_BACKGROUND_STATUSES: ReadonlySet<BackgroundCompletion["status"]> =
|
const ACTIVE_BACKGROUND_STATUSES: ReadonlySet<BackgroundCompletion["status"]> =
|
||||||
new Set(["pending", "running"]);
|
new Set(["pending", "running", "cancelling"]);
|
||||||
|
|
||||||
export function projectActivityStatus(
|
export function projectActivityStatus(
|
||||||
state: ProjectWorkState | null | undefined,
|
state: ProjectWorkState | null | undefined,
|
||||||
|
|||||||
@ -507,17 +507,23 @@ function AgentLiveRow({
|
|||||||
|
|
||||||
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
|
const TASK_STATUS_LABEL: Record<BackgroundCompletion["status"], string> = {
|
||||||
running: "Running",
|
running: "Running",
|
||||||
|
cancelling: "Cancelling",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
|
cancelFailed: "Cancel failed",
|
||||||
cancelled: "Cancelled",
|
cancelled: "Cancelled",
|
||||||
pending: "Pending",
|
pending: "Pending",
|
||||||
delivered: "Delivered",
|
delivered: "Delivered",
|
||||||
};
|
};
|
||||||
|
|
||||||
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
||||||
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
|
if (status === "running" || status === "pending" || status === "cancelling") {
|
||||||
|
return "bg-warning/15 text-warning";
|
||||||
|
}
|
||||||
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
||||||
if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger";
|
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
|
||||||
|
return "bg-danger/10 text-danger";
|
||||||
|
}
|
||||||
return "bg-raised text-muted";
|
return "bg-raised text-muted";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -92,10 +92,14 @@ function formatTaskStatus(status: BackgroundCompletion["status"]): string {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case "running":
|
case "running":
|
||||||
return "Running";
|
return "Running";
|
||||||
|
case "cancelling":
|
||||||
|
return "Cancelling";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "Completed";
|
return "Completed";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "Failed";
|
return "Failed";
|
||||||
|
case "cancelFailed":
|
||||||
|
return "Cancel failed";
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return "Cancelled";
|
return "Cancelled";
|
||||||
case "delivered":
|
case "delivered":
|
||||||
@ -107,16 +111,33 @@ function formatTaskStatus(status: BackgroundCompletion["status"]): string {
|
|||||||
|
|
||||||
/** Terminal (finished) task states that carry a result summary (ticket #5). */
|
/** Terminal (finished) task states that carry a result summary (ticket #5). */
|
||||||
function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean {
|
function isTerminalTaskStatus(status: BackgroundCompletion["status"]): boolean {
|
||||||
return status === "completed" || status === "failed" || status === "cancelled";
|
return (
|
||||||
|
status === "completed" ||
|
||||||
|
status === "failed" ||
|
||||||
|
status === "cancelFailed" ||
|
||||||
|
status === "cancelled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
function taskStatusClass(status: BackgroundCompletion["status"]): string {
|
||||||
if (status === "running" || status === "pending") return "bg-warning/15 text-warning";
|
if (status === "running" || status === "pending" || status === "cancelling") {
|
||||||
|
return "bg-warning/15 text-warning";
|
||||||
|
}
|
||||||
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
if (status === "completed" || status === "delivered") return "bg-success/10 text-success";
|
||||||
if (status === "failed" || status === "cancelled") return "bg-danger/10 text-danger";
|
if (status === "failed" || status === "cancelFailed" || status === "cancelled") {
|
||||||
|
return "bg-danger/10 text-danger";
|
||||||
|
}
|
||||||
return "bg-raised text-muted";
|
return "bg-raised text-muted";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canCancelTask(status: BackgroundCompletion["status"]): boolean {
|
||||||
|
return status === "running" || status === "pending" || status === "cancelling";
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRetryTask(status: BackgroundCompletion["status"]): boolean {
|
||||||
|
return status === "failed" || status === "cancelFailed" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
function requesterLabel(ticket: AgentTicketState): string {
|
function requesterLabel(ticket: AgentTicketState): string {
|
||||||
if (ticket.source.kind === "human") return "Human";
|
if (ticket.source.kind === "human") return "Human";
|
||||||
const id = shortTicket(ticket.source.agentId);
|
const id = shortTicket(ticket.source.agentId);
|
||||||
@ -346,8 +367,8 @@ function BackgroundTaskRow({
|
|||||||
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
|
const attachmentRef = useRef<BackgroundTaskAttachment | null>(null);
|
||||||
const attachSeqRef = useRef(0);
|
const attachSeqRef = useRef(0);
|
||||||
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
|
const hasOutput = Boolean(task.stdoutTail || task.stderrTail);
|
||||||
const canCancel = task.status === "running" || task.status === "pending";
|
const canCancel = canCancelTask(task.status);
|
||||||
const canRetry = task.status === "failed" || task.status === "cancelled";
|
const canRetry = canRetryTask(task.status);
|
||||||
const canAttachLive = canCancel;
|
const canAttachLive = canCancel;
|
||||||
|
|
||||||
function detachLive(): void {
|
function detachLive(): void {
|
||||||
|
|||||||
@ -224,6 +224,21 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
createdAtMs: 16,
|
createdAtMs: 16,
|
||||||
updatedAtMs: 17,
|
updatedAtMs: 17,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
taskId: "task-cancelling-1",
|
||||||
|
kind: "command",
|
||||||
|
state: "cancelling",
|
||||||
|
createdAtMs: 18,
|
||||||
|
updatedAtMs: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
taskId: "task-cancel-failed-1",
|
||||||
|
kind: "command",
|
||||||
|
state: "cancelFailed",
|
||||||
|
summary: "kill denied",
|
||||||
|
createdAtMs: 20,
|
||||||
|
updatedAtMs: 21,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -237,6 +252,8 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
["task-queued-1", "pending"],
|
["task-queued-1", "pending"],
|
||||||
["task-failed-1", "failed"],
|
["task-failed-1", "failed"],
|
||||||
["task-expired-1", "cancelled"],
|
["task-expired-1", "cancelled"],
|
||||||
|
["task-cancelling-1", "cancelling"],
|
||||||
|
["task-cancel-failed-1", "cancelFailed"],
|
||||||
]);
|
]);
|
||||||
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
|
// Optional fields omitted by the backend normalize to null (not undefined/NaN).
|
||||||
expect(tasks[0]?.exitCode).toBeNull();
|
expect(tasks[0]?.exitCode).toBeNull();
|
||||||
@ -249,25 +266,44 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
expect(tasks[1]?.targetAgentId).toBe("agent-bg");
|
expect(tasks[1]?.targetAgentId).toBe("agent-bg");
|
||||||
expect(tasks[1]?.conversationId).toBe("conversation-rdv");
|
expect(tasks[1]?.conversationId).toBe("conversation-rdv");
|
||||||
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
|
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
|
||||||
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
|
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17, 19, 21]);
|
||||||
|
|
||||||
renderPanel(workState);
|
renderPanel(workState);
|
||||||
|
|
||||||
const list = await screen.findByLabelText("Runner background tasks");
|
const list = await screen.findByLabelText("Runner background tasks");
|
||||||
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
|
const cancelButtons = within(list).getAllByRole("button", { name: "Cancel" });
|
||||||
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
|
const retryButtons = within(list).getAllByRole("button", { name: "Retry" });
|
||||||
// waiting + queued are cancellable; failed + expired are retryable.
|
// waiting + queued + cancelling are cancellable; failed + expired + cancelFailed are retryable.
|
||||||
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
|
expect(cancelButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
|
||||||
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(2);
|
expect(retryButtons.filter((b) => !(b as HTMLButtonElement).disabled)).toHaveLength(3);
|
||||||
|
expect(list.textContent).toContain("Cancelling");
|
||||||
|
expect(list.textContent).toContain("Cancel failed");
|
||||||
|
const rows = within(list).getAllByRole("listitem");
|
||||||
|
const rowTexts = rows.map((row) => row.textContent ?? "");
|
||||||
|
expect(rowTexts).toHaveLength(6);
|
||||||
|
const cancelFailedRow = rows[0] as HTMLElement;
|
||||||
|
const cancellingRow = rows[1] as HTMLElement;
|
||||||
|
expect(within(cancellingRow).getByText("Cancelling")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(within(cancellingRow).getByRole("button", {
|
||||||
|
name: "Cancel",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
expect(within(cancelFailedRow).getByText("Cancel failed")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
(within(cancelFailedRow).getByRole("button", {
|
||||||
|
name: "Retry",
|
||||||
|
}) as HTMLButtonElement).disabled,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
// Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as
|
// Rows are ordered most-recently-updated first (updatedAtMs desc), taskId as
|
||||||
// the deterministic tiebreak — locks the fix for the dead finishedAtMs sort.
|
// the deterministic tiebreak — locks the fix for the dead finishedAtMs sort.
|
||||||
const text = list.textContent ?? "";
|
expect(rowTexts[0]).toContain("Cancel failed");
|
||||||
const order = ["task-exp", "task-fai", "task-que", "task-wai"].map((id) =>
|
expect(rowTexts[1]).toContain("Cancelling");
|
||||||
text.indexOf(id),
|
expect(rowTexts[2]).toContain("Cancelled");
|
||||||
);
|
expect(rowTexts[3]).toContain("Failed");
|
||||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
expect(rowTexts[4]).toContain("Pending");
|
||||||
expect(order.every((i) => i >= 0)).toBe(true);
|
expect(rowTexts[5]).toContain("Pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => {
|
it("shows a terminal task summary and hides it when absent or non-terminal (#5)", async () => {
|
||||||
|
|||||||
@ -142,7 +142,15 @@ export interface BackgroundTaskStatus {
|
|||||||
ownerAgentId: string;
|
ownerAgentId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered";
|
status:
|
||||||
|
| "pending"
|
||||||
|
| "running"
|
||||||
|
| "cancelling"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelFailed"
|
||||||
|
| "cancelled"
|
||||||
|
| "delivered";
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
stdoutTail: string | null;
|
stdoutTail: string | null;
|
||||||
@ -178,7 +186,16 @@ export interface CommandTaskStatus {
|
|||||||
ownerAgentId: string;
|
ownerAgentId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired";
|
state:
|
||||||
|
| "queued"
|
||||||
|
| "running"
|
||||||
|
| "waiting"
|
||||||
|
| "cancelling"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "cancelFailed"
|
||||||
|
| "cancelled"
|
||||||
|
| "expired";
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
summary: string | null;
|
summary: string | null;
|
||||||
stdoutTail: string | null;
|
stdoutTail: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user