feat(background-task): rendu live des tâches de fond — subscriber UI + canal IPC (#58)
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>
This commit is contained in:
@ -17,6 +17,7 @@ tokio = { workspace = true, features = ["process", "time"] }
|
||||
uuid = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures-util = { workspace = true }
|
||||
fs4 = { workspace = true }
|
||||
# Ergonomic error enums for the MCP adapter (tool-mapping / transport errors).
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@ -101,6 +101,17 @@ impl CommandBackgroundRunner {
|
||||
u64::try_from(self.clock.now_millis().max(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns the live PTY handle for a running task, when this runner still
|
||||
/// owns one.
|
||||
#[must_use]
|
||||
pub fn pty_handle_for(&self, task_id: TaskId) -> Option<PtyHandle> {
|
||||
self.running
|
||||
.lock()
|
||||
.expect("runner registry poisoned")
|
||||
.get(&task_id)
|
||||
.map(|control| control.pty_handle.clone())
|
||||
}
|
||||
|
||||
/// Detached worker driving one command to its single completion.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn run_to_completion(
|
||||
|
||||
@ -25,6 +25,7 @@ pub mod id;
|
||||
pub mod input;
|
||||
pub mod inspector;
|
||||
pub mod issues;
|
||||
pub mod lock;
|
||||
pub mod mailbox;
|
||||
pub mod model_catalogue;
|
||||
pub mod model_server;
|
||||
@ -68,6 +69,7 @@ pub use inspector::{
|
||||
transcript_activity_token, ClaudeTranscriptInspector, ClaudeTranscriptTurnWatcher,
|
||||
};
|
||||
pub use issues::{FsIssueNumberAllocator, FsIssueStore};
|
||||
pub use lock::{acquire_app_data_dir_lock, AppDataDirLock, AppDataDirLockError};
|
||||
pub use mailbox::InMemoryMailbox;
|
||||
pub use model_catalogue::{
|
||||
EmbeddedCompatibilityMatrix, HttpProviderModelCatalogue, ProcessCliVersionReader,
|
||||
|
||||
@ -53,15 +53,6 @@ impl FakePty {
|
||||
self.state.lock().unwrap().scrollback = bytes.to_vec();
|
||||
}
|
||||
|
||||
fn last_handle(&self) -> PtyHandle {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.handle
|
||||
.clone()
|
||||
.expect("spawned handle")
|
||||
}
|
||||
|
||||
fn kill_count(&self) -> usize {
|
||||
self.state.lock().unwrap().kills
|
||||
}
|
||||
@ -205,7 +196,9 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
|
||||
.spawn(spawn_spec(task_id, None))
|
||||
.await
|
||||
.expect("spawn succeeds");
|
||||
let handle = pty.last_handle();
|
||||
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));
|
||||
|
||||
@ -229,6 +222,68 @@ async fn ui_subscriber_does_not_interfere_with_runner_completion() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
|
||||
Reference in New Issue
Block a user