Câble un canal attachable au flux de sortie d'une tâche de fond (runner infrastructure + commande app-tauri + port/adaptateurs frontend) et le panneau ProjectWorkStatePanel s'y abonne pour un rendu live au lieu d'un état figé au dernier snapshot. Validations obtenues avant commit : - cargo test -p infrastructure --test background_task_runner : vert - cargo check -p backend -p app-tauri : vert - npx vitest run src/features/workstate/workstate.test.tsx : vert - npm run typecheck : vert - verdict QA #58 : vert Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
10 KiB
Rust
347 lines
10 KiB
Rust
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use domain::ports::{
|
|
BackgroundTaskRunner, Clock, ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec,
|
|
};
|
|
use domain::{
|
|
AgentId, BackgroundTaskKind, BackgroundTaskResult, BackgroundTaskWakePolicy, ProjectId,
|
|
ProjectPath, PtySize, SessionId, TaskId,
|
|
};
|
|
use infrastructure::CommandBackgroundRunner;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Default)]
|
|
struct FakeClock(Mutex<i64>);
|
|
|
|
impl FakeClock {
|
|
fn set(&self, now: i64) {
|
|
*self.0.lock().unwrap() = now;
|
|
}
|
|
}
|
|
|
|
impl Clock for FakeClock {
|
|
fn now_millis(&self) -> i64 {
|
|
*self.0.lock().unwrap()
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakePtyState {
|
|
handle: Option<PtyHandle>,
|
|
status: Option<ExitStatus>,
|
|
kills: usize,
|
|
scrollback: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakePty {
|
|
state: Mutex<FakePtyState>,
|
|
wait_changed: tokio::sync::Notify,
|
|
subscribe_count: AtomicUsize,
|
|
}
|
|
|
|
impl FakePty {
|
|
fn complete(&self, code: Option<i32>) {
|
|
self.state.lock().unwrap().status = Some(ExitStatus { code });
|
|
self.wait_changed.notify_waiters();
|
|
}
|
|
|
|
fn set_scrollback(&self, bytes: &[u8]) {
|
|
self.state.lock().unwrap().scrollback = bytes.to_vec();
|
|
}
|
|
|
|
fn kill_count(&self) -> usize {
|
|
self.state.lock().unwrap().kills
|
|
}
|
|
|
|
fn subscribe_count(&self) -> usize {
|
|
self.subscribe_count.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
|
let handle = PtyHandle {
|
|
session_id: SessionId::from_uuid(Uuid::from_u128(0x777)),
|
|
};
|
|
self.state.lock().unwrap().handle = Some(handle.clone());
|
|
Ok(handle)
|
|
}
|
|
|
|
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
|
self.subscribe_count.fetch_add(1, Ordering::SeqCst);
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
|
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(self.state.lock().unwrap().scrollback.clone())
|
|
}
|
|
|
|
async fn wait(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
loop {
|
|
if let Some(status) = self.state.lock().unwrap().status {
|
|
return Ok(status);
|
|
}
|
|
self.wait_changed.notified().await;
|
|
}
|
|
}
|
|
|
|
fn try_wait(&self, _handle: &PtyHandle) -> Result<Option<ExitStatus>, PtyError> {
|
|
Ok(self.state.lock().unwrap().status)
|
|
}
|
|
|
|
async fn kill(&self, _handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
let mut state = self.state.lock().unwrap();
|
|
state.kills += 1;
|
|
let status = state.status.get_or_insert(ExitStatus { code: Some(130) });
|
|
Ok(*status)
|
|
}
|
|
}
|
|
|
|
fn id(n: u128) -> Uuid {
|
|
Uuid::from_u128(n)
|
|
}
|
|
|
|
fn spawn_spec(task_id: TaskId, deadline_ms: Option<u64>) -> domain::ports::BackgroundTaskSpec {
|
|
domain::ports::BackgroundTaskSpec {
|
|
task_id,
|
|
project_id: ProjectId::from_uuid(id(1)),
|
|
owner_agent_id: AgentId::from_uuid(id(2)),
|
|
kind: BackgroundTaskKind::Command {
|
|
label: "test command".to_owned(),
|
|
},
|
|
wake_policy: BackgroundTaskWakePolicy::RecordOnly,
|
|
command: Some(SpawnSpec {
|
|
command: "/bin/sh".to_owned(),
|
|
args: vec!["-c".to_owned(), "echo test".to_owned()],
|
|
cwd: ProjectPath::new("/tmp").unwrap(),
|
|
env: Vec::new(),
|
|
context_plan: None,
|
|
sandbox: None,
|
|
}),
|
|
deadline_ms,
|
|
}
|
|
}
|
|
|
|
fn runner_with(
|
|
pty: Arc<FakePty>,
|
|
clock: Arc<FakeClock>,
|
|
) -> (
|
|
CommandBackgroundRunner,
|
|
domain::ports::BackgroundCompletionStream,
|
|
) {
|
|
let runner = CommandBackgroundRunner::new(pty, clock);
|
|
let completions = runner.subscribe_completions();
|
|
(runner, completions)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn completes_from_wait_without_draining_output() {
|
|
let pty = Arc::new(FakePty::default());
|
|
pty.set_scrollback(b"hello from scrollback");
|
|
let clock = Arc::new(FakeClock::default());
|
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
|
|
let task_id = TaskId::from_uuid(id(10));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, None))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
pty.complete(Some(0));
|
|
|
|
let completion = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
|
)
|
|
.await
|
|
.expect("completion arrives")
|
|
.expect("completion thread joins");
|
|
|
|
assert_eq!(completion.task_id, task_id);
|
|
match completion.result {
|
|
BackgroundTaskResult::Success {
|
|
exit_code,
|
|
stdout_tail,
|
|
..
|
|
} => {
|
|
assert_eq!(exit_code, Some(0));
|
|
assert_eq!(stdout_tail.as_deref(), Some("hello from scrollback"));
|
|
}
|
|
other => panic!("expected success, got {other:?}"),
|
|
}
|
|
assert_eq!(pty.subscribe_count(), 0, "runner must not drain output");
|
|
assert_eq!(pty.kill_count(), 1, "natural exit is cleaned up once");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn ui_subscriber_does_not_interfere_with_runner_completion() {
|
|
let pty = Arc::new(FakePty::default());
|
|
let clock = Arc::new(FakeClock::default());
|
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
|
|
let task_id = TaskId::from_uuid(id(11));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, None))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
let handle = runner
|
|
.pty_handle_for(task_id)
|
|
.expect("runner exposes live PTY handle");
|
|
let _ui_stream = pty.subscribe_output(&handle).expect("ui subscribes");
|
|
pty.complete(Some(0));
|
|
|
|
let completion = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
|
)
|
|
.await
|
|
.expect("completion arrives")
|
|
.expect("completion thread joins");
|
|
|
|
assert_eq!(completion.task_id, task_id);
|
|
assert!(matches!(
|
|
completion.result,
|
|
BackgroundTaskResult::Success { .. }
|
|
));
|
|
assert_eq!(
|
|
pty.subscribe_count(),
|
|
1,
|
|
"only the explicit UI subscription should exist"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn double_ui_subscriber_uses_independent_pty_streams() {
|
|
let pty = Arc::new(FakePty::default());
|
|
let clock = Arc::new(FakeClock::default());
|
|
let (runner, _completions) = runner_with(Arc::clone(&pty), clock);
|
|
let task_id = TaskId::from_uuid(id(14));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, None))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
let handle = runner
|
|
.pty_handle_for(task_id)
|
|
.expect("runner exposes live PTY handle");
|
|
|
|
let _first = pty.subscribe_output(&handle).expect("first ui subscribes");
|
|
let _second = pty.subscribe_output(&handle).expect("second ui subscribes");
|
|
|
|
assert_eq!(
|
|
pty.subscribe_count(),
|
|
2,
|
|
"each UI attach must get its own PTY subscription"
|
|
);
|
|
assert_eq!(
|
|
runner.pty_handle_for(task_id),
|
|
Some(handle),
|
|
"UI subscribers must not remove the runner's live handle"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn completed_task_no_longer_exposes_live_pty_handle_for_attach() {
|
|
let pty = Arc::new(FakePty::default());
|
|
pty.set_scrollback(b"final tail");
|
|
let clock = Arc::new(FakeClock::default());
|
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
|
|
let task_id = TaskId::from_uuid(id(15));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, None))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
assert!(runner.pty_handle_for(task_id).is_some());
|
|
pty.complete(Some(0));
|
|
|
|
let completion = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
|
)
|
|
.await
|
|
.expect("completion arrives")
|
|
.expect("completion thread joins");
|
|
|
|
assert_eq!(completion.task_id, task_id);
|
|
assert_eq!(runner.pty_handle_for(task_id), None);
|
|
assert_eq!(
|
|
pty.subscribe_count(),
|
|
0,
|
|
"attach after completion must not subscribe to a dead PTY handle"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn deadline_expires_and_kills_when_wait_never_resolves() {
|
|
let pty = Arc::new(FakePty::default());
|
|
let clock = Arc::new(FakeClock::default());
|
|
clock.set(100);
|
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), Arc::clone(&clock));
|
|
let task_id = TaskId::from_uuid(id(12));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, Some(101)))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
|
|
let completion = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
|
)
|
|
.await
|
|
.expect("completion arrives")
|
|
.expect("completion thread joins");
|
|
|
|
assert_eq!(completion.task_id, task_id);
|
|
assert!(matches!(
|
|
completion.result,
|
|
BackgroundTaskResult::Expired { .. }
|
|
));
|
|
assert_eq!(pty.kill_count(), 1);
|
|
assert_eq!(pty.subscribe_count(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn cancel_wins_even_if_output_is_still_available() {
|
|
let pty = Arc::new(FakePty::default());
|
|
pty.set_scrollback(b"still streaming");
|
|
let clock = Arc::new(FakeClock::default());
|
|
let (runner, mut completions) = runner_with(Arc::clone(&pty), clock);
|
|
let task_id = TaskId::from_uuid(id(13));
|
|
|
|
runner
|
|
.spawn(spawn_spec(task_id, None))
|
|
.await
|
|
.expect("spawn succeeds");
|
|
runner.cancel(task_id).await.expect("cancel succeeds");
|
|
|
|
let completion = tokio::time::timeout(
|
|
Duration::from_secs(1),
|
|
tokio::task::spawn_blocking(move || completions.next().expect("completion")),
|
|
)
|
|
.await
|
|
.expect("completion arrives")
|
|
.expect("completion thread joins");
|
|
|
|
assert_eq!(completion.task_id, task_id);
|
|
assert!(matches!(
|
|
completion.result,
|
|
BackgroundTaskResult::Cancelled { .. }
|
|
));
|
|
assert_eq!(pty.kill_count(), 1);
|
|
assert_eq!(pty.subscribe_count(), 0);
|
|
}
|