feat(domain): modèle de tâches de fond de 1re classe et mailbox (B1)

Entités BackgroundTask et Inbox, identifiants, événements et ports du
domaine pour la récupération de complétion post-tour et la réception de
messages concurrents. Fondations des lots infra/application suivants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:47:16 +02:00
parent fccc1e2f0f
commit f4a55e9988
7 changed files with 1276 additions and 11 deletions

View File

@ -0,0 +1,302 @@
//! Pure invariants for first-class background tasks.
use domain::{
BackgroundTask, BackgroundTaskError, BackgroundTaskKind, BackgroundTaskResult,
BackgroundTaskState, BackgroundTaskWakePolicy,
};
use uuid::Uuid;
fn task_id(n: u128) -> domain::TaskId {
domain::TaskId::from_uuid(Uuid::from_u128(n))
}
fn project_id(n: u128) -> domain::ProjectId {
domain::ProjectId::from_uuid(Uuid::from_u128(n))
}
fn agent_id(n: u128) -> domain::AgentId {
domain::AgentId::from_uuid(Uuid::from_u128(n))
}
fn command_task() -> BackgroundTask {
BackgroundTask::new(
task_id(1),
project_id(2),
agent_id(3),
BackgroundTaskKind::Command {
label: "build".into(),
},
BackgroundTaskWakePolicy::WakeOwner,
100,
Some(200),
)
.unwrap()
}
fn success_result(at: u64) -> BackgroundTaskResult {
BackgroundTaskResult::Success {
finished_at_ms: at,
exit_code: Some(0),
summary: "ok".into(),
stdout_tail: None,
stderr_tail: None,
}
}
fn failure_result(at: u64) -> BackgroundTaskResult {
BackgroundTaskResult::Failure {
finished_at_ms: at,
exit_code: Some(1),
error: "failed".into(),
stdout_tail: None,
stderr_tail: None,
}
}
#[test]
fn new_task_is_queued_without_result_or_delivery() {
let task = command_task();
assert_eq!(task.state, BackgroundTaskState::Queued);
assert_eq!(task.result, None);
assert!(!task.completion_delivered);
assert!(!task.is_terminal());
assert!(!task.has_pending_completion_delivery());
}
#[test]
fn deadline_must_not_precede_creation() {
let err = BackgroundTask::new(
task_id(1),
project_id(2),
agent_id(3),
BackgroundTaskKind::Command {
label: "build".into(),
},
BackgroundTaskWakePolicy::WakeOwner,
100,
Some(99),
)
.unwrap_err();
assert_eq!(err, BackgroundTaskError::InvalidTimestamps);
}
#[test]
fn labels_must_be_non_empty_and_bounded() {
let empty = BackgroundTaskKind::Command { label: " ".into() };
assert_eq!(empty.validate(), Err(BackgroundTaskError::EmptyLabel));
let oversized = BackgroundTaskKind::Maintenance {
label: "x".repeat(domain::BACKGROUND_TASK_LABEL_MAX_CHARS + 1),
};
assert_eq!(
oversized.validate(),
Err(BackgroundTaskError::FieldTooLarge {
field: "label",
max: domain::BACKGROUND_TASK_LABEL_MAX_CHARS,
})
);
}
#[test]
fn allowed_non_terminal_transitions_update_timestamp() {
let running = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap();
assert_eq!(running.state, BackgroundTaskState::Running);
assert_eq!(running.updated_at_ms, 120);
let waiting = running
.transition(BackgroundTaskState::Waiting, 130)
.unwrap();
assert_eq!(waiting.state, BackgroundTaskState::Waiting);
let running_again = waiting
.transition(BackgroundTaskState::Running, 140)
.unwrap();
assert_eq!(running_again.state, BackgroundTaskState::Running);
}
#[test]
fn illegal_transitions_are_rejected() {
let task = command_task();
let err = task
.transition(BackgroundTaskState::Waiting, 120)
.unwrap_err();
assert_eq!(
err,
BackgroundTaskError::InvalidTransition {
from: BackgroundTaskState::Queued,
to: BackgroundTaskState::Waiting,
}
);
}
#[test]
fn lifecycle_timestamps_must_not_regress() {
let running = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap();
assert_eq!(
running.transition(BackgroundTaskState::Waiting, 119),
Err(BackgroundTaskError::InvalidTimestamps)
);
assert_eq!(
running.complete(success_result(119)),
Err(BackgroundTaskError::InvalidTimestamps)
);
}
#[test]
fn terminal_transition_requires_complete_with_matching_result() {
let running = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap();
assert_eq!(
running.transition(BackgroundTaskState::Completed, 130),
Err(BackgroundTaskError::MissingResult)
);
let completed = running.complete(success_result(130)).unwrap();
assert_eq!(completed.state, BackgroundTaskState::Completed);
assert_eq!(completed.updated_at_ms, 130);
assert!(completed.is_terminal());
assert!(completed.has_pending_completion_delivery());
}
#[test]
fn terminal_states_are_immutable() {
let completed = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap()
.complete(success_result(130))
.unwrap();
let err = completed
.transition(BackgroundTaskState::Running, 140)
.unwrap_err();
assert_eq!(
err,
BackgroundTaskError::InvalidTransition {
from: BackgroundTaskState::Completed,
to: BackgroundTaskState::Running,
}
);
let err = completed.complete(failure_result(140)).unwrap_err();
assert_eq!(
err,
BackgroundTaskError::InvalidTransition {
from: BackgroundTaskState::Completed,
to: BackgroundTaskState::Failed,
}
);
}
#[test]
fn result_variants_determine_terminal_state() {
let running = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap();
assert_eq!(
running.complete(failure_result(130)).unwrap().state,
BackgroundTaskState::Failed
);
assert_eq!(
running
.complete(BackgroundTaskResult::Cancelled {
finished_at_ms: 130,
reason: "user".into(),
})
.unwrap()
.state,
BackgroundTaskState::Cancelled
);
assert_eq!(
running
.complete(BackgroundTaskResult::Expired {
finished_at_ms: 130,
reason: "deadline".into(),
})
.unwrap()
.state,
BackgroundTaskState::Expired
);
}
#[test]
fn completion_delivery_only_once_after_terminal_state() {
let running = command_task()
.transition(BackgroundTaskState::Running, 120)
.unwrap();
assert_eq!(
running.mark_completion_delivered(),
Err(BackgroundTaskError::MissingResult)
);
let delivered = running
.complete(success_result(130))
.unwrap()
.mark_completion_delivered()
.unwrap();
assert!(delivered.completion_delivered);
assert!(!delivered.has_pending_completion_delivery());
assert_eq!(
delivered.mark_completion_delivered(),
Err(BackgroundTaskError::CompletionAlreadyDelivered)
);
}
#[test]
fn result_text_fields_are_bounded() {
let summary_too_large = BackgroundTaskResult::Success {
finished_at_ms: 1,
exit_code: None,
summary: "x".repeat(domain::BACKGROUND_TASK_TEXT_MAX_BYTES + 1),
stdout_tail: None,
stderr_tail: None,
};
assert_eq!(
summary_too_large.validate(),
Err(BackgroundTaskError::FieldTooLarge {
field: "summary",
max: domain::BACKGROUND_TASK_TEXT_MAX_BYTES,
})
);
let tail_too_large = BackgroundTaskResult::Failure {
finished_at_ms: 1,
exit_code: None,
error: "failed".into(),
stdout_tail: Some("x".repeat(domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES + 1)),
stderr_tail: None,
};
assert_eq!(
tail_too_large.validate(),
Err(BackgroundTaskError::FieldTooLarge {
field: "stdout_tail",
max: domain::BACKGROUND_TASK_OUTPUT_TAIL_MAX_BYTES,
})
);
}
#[test]
fn background_task_serializes_with_camel_case_fields() {
let task = command_task();
let json = serde_json::to_string(&task).unwrap();
assert!(json.contains("\"ownerAgentId\""), "json was {json}");
assert!(
json.contains("\"wakePolicy\":\"wakeOwner\""),
"json was {json}"
);
assert!(json.contains("\"completionDelivered\""), "json was {json}");
assert_eq!(serde_json::from_str::<BackgroundTask>(&json).unwrap(), task);
}