From adef4f1637ddd72efd11ad004df8ed2369c37ce8 Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Sat, 25 Jul 2026 09:34:19 +0200 Subject: [PATCH 1/2] feat(daemon): add worktree picker to changes sidebar - Add WorktreePicker dropdown component showing all git worktrees - Extend WorktreeManager::discover() to find all worktrees via 'git worktree list --porcelain' - Call discover() on daemon startup for each project - Add lazy init in list_worktrees handler for on-demand discovery - Add path override to diff.get, file.contents, file.list, file.save, diff.resolveHunk, git.commit - Auto-resolve task.worktree path when task runs in isolated worktree - Improve detached worktree naming: show dirname + '(detached)' - Rename 'Main project' to 'Working tree' in picker --- crates/warpforge-protocol/src/lib.rs | 23 +++- desktop/src/components/ChangesRail.tsx | 15 ++- .../src/components/changes/WorktreePicker.tsx | 68 ++++++++++ desktop/src/views/TaskDetail.tsx | 5 +- .../src/views/task-detail/useTaskQueries.ts | 11 +- src/daemon/actor.rs | 117 ++++++++++++---- src/daemon/server.rs | 22 ++- src/daemon/worktree.rs | 126 +++++++++++++----- 8 files changed, 315 insertions(+), 72 deletions(-) create mode 100644 desktop/src/components/changes/WorktreePicker.tsx diff --git a/crates/warpforge-protocol/src/lib.rs b/crates/warpforge-protocol/src/lib.rs index 75c6877..ccdfdbe 100644 --- a/crates/warpforge-protocol/src/lib.rs +++ b/crates/warpforge-protocol/src/lib.rs @@ -285,18 +285,31 @@ pub enum Method { // ── Diff / review ── #[serde(rename = "diff.get")] - DiffGet { task_id: String }, + DiffGet { + task_id: String, + /// Explicit repo path override (view any worktree). Falls back to the + /// task's worktree, then its project path. + #[serde(default)] + path: Option, + }, #[serde(rename = "diff.resolveHunk")] DiffResolveHunk { task_id: String, file: String, hunk_index: u32, resolution: HunkResolution, + #[serde(default)] + path: Option, }, /// Full old (HEAD) + new (working-tree) contents of one file — powers the /// editable side-by-side (CodeMirror merge) review. #[serde(rename = "file.contents")] - FileContents { task_id: String, path: String }, + FileContents { + task_id: String, + path: String, + #[serde(default, rename = "repoPath")] + repo_path: Option, + }, /// List files in the task's project working tree. #[serde(rename = "file.list")] FileList { @@ -308,6 +321,8 @@ pub enum Method { /// `@` picker does not — node_modules/target swamp it). #[serde(default)] include_ignored: bool, + #[serde(default)] + path: Option, }, /// Write new contents to a file in the task's working tree (in-review edit). #[serde(rename = "file.save")] @@ -315,6 +330,8 @@ pub enum Method { task_id: String, path: String, content: String, + #[serde(default, rename = "repoPath")] + repo_path: Option, }, /// Stage files and commit them in the task's repo. `files=None` stages all /// changes; `amend` rewrites the previous commit. @@ -326,6 +343,8 @@ pub enum Method { files: Option>, #[serde(default)] amend: bool, + #[serde(default)] + path: Option, }, /// Pull the task's project repo up to its upstream (rebase + autostash). /// Any conflict rolls the working tree back to the exact prior state. diff --git a/desktop/src/components/ChangesRail.tsx b/desktop/src/components/ChangesRail.tsx index 10e3258..7eb069d 100644 --- a/desktop/src/components/ChangesRail.tsx +++ b/desktop/src/components/ChangesRail.tsx @@ -9,6 +9,7 @@ import { daemon } from "../daemon"; import type { FileDiff } from "../protocol"; import { CommitBox } from "./changes/CommitBox"; import { FileTreeRow } from "./changes/FileTreeRow"; +import { WorktreePicker } from "./changes/WorktreePicker"; import { buildTree, collectFolderKeys, @@ -39,6 +40,8 @@ export function ChangesRail({ onCommitExpandedChange, onCommitted, onRefresh, + worktreePath, + onWorktreeChange, }: { project: string; files: FileDiff[]; @@ -49,6 +52,8 @@ export function ChangesRail({ onCommitExpandedChange?: (expanded: boolean) => void; onCommitted: () => void; onRefresh: () => void; + worktreePath?: string | null; + onWorktreeChange?: (path: string | null) => void; }) { const allPaths = useMemo(() => files.map((f) => f.path), [files]); const filesByPath = useMemo(() => new Map(files.map((f) => [f.path, f])), [files]); @@ -228,7 +233,15 @@ export function ChangesRail({ return (
- Changes + Changes + {onWorktreeChange && ( + + )} + + + + onSelect(null)}> + Working tree + + + {worktrees.map((wt) => ( + onSelect(wt.path)}> + {wt.branch} + + + ))} + + + ); +} diff --git a/desktop/src/views/TaskDetail.tsx b/desktop/src/views/TaskDetail.tsx index f27b57f..e91635f 100644 --- a/desktop/src/views/TaskDetail.tsx +++ b/desktop/src/views/TaskDetail.tsx @@ -138,6 +138,7 @@ export default function TaskDetail({ task, snapshot, onClose, onOpenTask, onOpen const diffView = useUi((s) => s.diffView); const setDiffView = useUi((s) => s.setDiffView); const [selectedFile, setSelectedFile] = useState(null); + const [worktreePath, setWorktreePath] = useState(null); const showChat = useUi((s) => s.showChat); const showDiff = useUi((s) => s.showDiff); const rightPanel = useUi((s) => s.rightPanel); @@ -197,7 +198,7 @@ export default function TaskDetail({ task, snapshot, onClose, onOpenTask, onOpen mentionFilesQuery, fileDoc, queryClient, - } = useTaskQueries(task.id, activeFile, activeTab, task.updatedAt); + } = useTaskQueries(task.id, activeFile, activeTab, task.updatedAt, worktreePath); const setView = (v: "unified" | "split") => setDiffView(v); const openFileTab = useCallback( @@ -351,6 +352,8 @@ export default function TaskDetail({ task, snapshot, onClose, onOpenTask, onOpen files={diff.files} selected={selectedFile} taskId={task.id} + worktreePath={worktreePath} + onWorktreeChange={setWorktreePath} commitExpanded={commitExpanded} onCommitExpandedChange={setCommitExpanded} onCommitted={() => { diff --git a/desktop/src/views/task-detail/useTaskQueries.ts b/desktop/src/views/task-detail/useTaskQueries.ts index f592b01..18764fa 100644 --- a/desktop/src/views/task-detail/useTaskQueries.ts +++ b/desktop/src/views/task-detail/useTaskQueries.ts @@ -13,13 +13,14 @@ export function useTaskQueries( activeFile: string | null, activeTab: ActiveTab, updatedAt: number, + worktreePath?: string | null, ) { const queryClient = useQueryClient(); const diffQuery = useQuery({ placeholderData: keepPreviousData, - queryFn: daemonQuery("diff.get", { task_id: taskId }), - queryKey: ["diff", taskId], + queryFn: daemonQuery("diff.get", { path: worktreePath, task_id: taskId }), + queryKey: ["diff", taskId, worktreePath ?? null], refetchOnWindowFocus: "always", }); const diff = diffQuery.data ?? null; @@ -28,9 +29,10 @@ export function useTaskQueries( placeholderData: keepPreviousData, queryFn: daemonQuery("file.list", { include_ignored: true, + path: worktreePath, task_id: taskId, }), - queryKey: ["fileList", taskId, "all"], + queryKey: ["fileList", taskId, "all", worktreePath ?? null], }); const projectFiles = Array.isArray(fileListQuery.data) ? fileListQuery.data : EMPTY_PROJECT_FILES; const fileListError = fileListQuery.error?.message ?? null; @@ -51,8 +53,9 @@ export function useTaskQueries( queryFn: daemonQuery("file.contents", { task_id: taskId, path: activeFile, + repoPath: worktreePath, }), - queryKey: ["fileContents", taskId, activeFile], + queryKey: ["fileContents", taskId, activeFile, worktreePath ?? null], refetchOnWindowFocus: "always", }); const fileDoc = fileContentsEnabled ? (fileDocQuery.data ?? null) : null; diff --git a/src/daemon/actor.rs b/src/daemon/actor.rs index bd8a0ef..3734564 100644 --- a/src/daemon/actor.rs +++ b/src/daemon/actor.rs @@ -59,6 +59,19 @@ fn config_fingerprint(project_path: &Path) -> ConfigFingerprint { std::fs::read(&path).ok().map(|contents| (path, contents)) } +/// Resolve the repo path a git operation should run against, in priority order: +/// explicit `path_override` (view any worktree), the task's own `worktree`, then +/// the project's main path. +fn resolve_repo_path( + task: &Task, + path_override: Option, + project_path: Option, +) -> Option { + path_override + .or_else(|| task.worktree.clone()) + .or(project_path) +} + /// Content-based, debounced observer for registered project configs. /// /// Resolving the active config path on each pass rather than tracking one inode @@ -474,12 +487,14 @@ pub enum Command { /// Compute the task's working-tree diff (git). GetDiff { task_id: String, + path_override: Option, reply: oneshot::Sender, }, /// Old (HEAD) + new (working-tree) text of one file. GetFileContents { task_id: String, path: String, + path_override: Option, reply: oneshot::Sender>, }, /// List files in a task's project working tree. @@ -487,6 +502,7 @@ pub enum Command { task_id: String, project: Option, include_ignored: bool, + path_override: Option, reply: oneshot::Sender>, }, /// Write new contents to a file in the task's working tree. @@ -494,6 +510,7 @@ pub enum Command { task_id: String, path: String, content: String, + path_override: Option, }, /// Accept (keep) or reject (revert) a single hunk in the working tree. ResolveHunk { @@ -501,6 +518,7 @@ pub enum Command { file: String, hunk_index: u32, resolution: wire::HunkResolution, + path_override: Option, }, /// Stage (optionally a subset of) files and commit them in the task's repo. GitCommit { @@ -508,6 +526,7 @@ pub enum Command { message: String, files: Option>, amend: bool, + path_override: Option, reply: oneshot::Sender>, }, /// Fetch + rebase the task's repo onto its upstream (autostash, rollback). @@ -870,21 +889,28 @@ impl DaemonHandle { rx.await.unwrap_or_default() } - pub async fn diff(&self, task_id: &str) -> wire::TaskDiff { + pub async fn diff(&self, task_id: &str, path_override: Option) -> wire::TaskDiff { let (tx, rx) = oneshot::channel(); self.send(Command::GetDiff { task_id: task_id.to_string(), + path_override, reply: tx, }) .await; rx.await.unwrap_or_default() } - pub async fn file_contents(&self, task_id: &str, path: &str) -> Option { + pub async fn file_contents( + &self, + task_id: &str, + path: &str, + path_override: Option, + ) -> Option { let (tx, rx) = oneshot::channel(); self.send(Command::GetFileContents { task_id: task_id.to_string(), path: path.to_string(), + path_override, reply: tx, }) .await; @@ -896,12 +922,14 @@ impl DaemonHandle { task_id: &str, project: Option, include_ignored: bool, + path_override: Option, ) -> Vec { let (tx, rx) = oneshot::channel(); self.send(Command::ListFiles { task_id: task_id.to_string(), project, include_ignored, + path_override, reply: tx, }) .await; @@ -914,6 +942,7 @@ impl DaemonHandle { message: &str, files: Option>, amend: bool, + path_override: Option, ) -> Result<(), String> { let (tx, rx) = oneshot::channel(); self.send(Command::GitCommit { @@ -921,6 +950,7 @@ impl DaemonHandle { message: message.to_string(), files, amend, + path_override, reply: tx, }) .await; @@ -1664,6 +1694,22 @@ impl Daemon { Update(oneshot::Sender>), } + // Discover existing worktrees on startup. + for project in self.projects.clone() { + if let Some(path) = self.project_path(&project.name) { + let wt_mgr = self + .worktrees + .entry(project.name.clone()) + .or_insert_with(|| WorktreeManager::new(std::path::PathBuf::from(&path))); + if let Err(e) = wt_mgr.discover().await { + eprintln!( + "[daemon] worktree discover failed for {}: {e}", + project.name + ); + } + } + } + let mut config_poll = tokio::time::interval(CONFIG_POLL_INTERVAL); config_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -2167,13 +2213,17 @@ impl Daemon { self.pending_wake.remove(&parent_task_id); let _ = reply.send(results); } - Command::GetDiff { task_id, reply } => { + Command::GetDiff { + task_id, + path_override, + reply, + } => { // Resolve the repo path (sync) before awaiting git, so no shared // borrow of self is held across the await. - let repo = self - .tasks - .get(&task_id) - .and_then(|t| self.project_path(&t.project)); + let repo = self.tasks.get(&task_id).and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override, pp) + }); let (files, branch) = match repo { Some(path) => ( super::diff::working_diff(&path).await.unwrap_or_default(), @@ -2190,12 +2240,13 @@ impl Daemon { Command::GetFileContents { task_id, path, + path_override, reply, } => { - let repo = self - .tasks - .get(&task_id) - .and_then(|t| self.project_path(&t.project)); + let repo = self.tasks.get(&task_id).and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override, pp) + }); let doc = match repo { Some(p) => super::diff::file_doc(&p, &path).await.ok(), None => None, @@ -2206,12 +2257,17 @@ impl Daemon { task_id, project, include_ignored, + path_override, reply, } => { let repo = self .tasks .get(&task_id) - .and_then(|t| self.project_path(&t.project)) + .and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override.clone(), pp) + }) + .or_else(|| path_override.clone()) .or_else(|| project.as_deref().and_then(|name| self.project_path(name))); let files = match repo { Some(p) => super::diff::list_files(&p, include_ignored) @@ -2225,11 +2281,12 @@ impl Daemon { task_id, path, content, + path_override, } => { - let repo = self - .tasks - .get(&task_id) - .and_then(|t| self.project_path(&t.project)); + let repo = self.tasks.get(&task_id).and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override, pp) + }); if let Some(p) = repo { if super::diff::save_file(&p, &path, &content).is_ok() { // Nudge clients so the diff/file list refetches. @@ -2247,13 +2304,14 @@ impl Daemon { file, hunk_index, resolution, + path_override, } => { // accept keeps the change (no-op); only reject touches the tree. if resolution == wire::HunkResolution::Reject { - let repo = self - .tasks - .get(&task_id) - .and_then(|t| self.project_path(&t.project)); + let repo = self.tasks.get(&task_id).and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override, pp) + }); if let Some(path) = repo { if super::diff::reject_hunk(&path, &file, hunk_index) .await @@ -2277,12 +2335,13 @@ impl Daemon { message, files, amend, + path_override, reply, } => { - let repo = self - .tasks - .get(&task_id) - .and_then(|t| self.project_path(&t.project)); + let repo = self.tasks.get(&task_id).and_then(|t| { + let pp = self.project_path(&t.project); + resolve_repo_path(t, path_override, pp) + }); let result = match repo { Some(p) => super::diff::commit(&p, &message, files.as_deref(), amend) .await @@ -2579,7 +2638,15 @@ impl Daemon { let _ = reply.send(result); } Command::ListWorktrees { project, reply } => { - let wts = if let Some(wt_mgr) = self.worktrees.get(&project) { + let path = self.project_path(&project); + let wts = if let Some(p) = path { + let wt_mgr = self + .worktrees + .entry(project.clone()) + .or_insert_with(|| WorktreeManager::new(std::path::PathBuf::from(&p))); + if wt_mgr.list().is_empty() { + let _ = wt_mgr.discover().await; + } wt_mgr .list() .into_iter() diff --git a/src/daemon/server.rs b/src/daemon/server.rs index dbbf656..9499a6d 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -439,8 +439,8 @@ async fn dispatch( let results = handle.read_inbox(&parent_task_id).await; Ok(json!({ "results": results })) } - DiffGet { task_id } => { - let diff = handle.diff(&task_id).await; + DiffGet { task_id, path } => { + let diff = handle.diff(&task_id, path).await; serde_json::to_value(diff).map_err(|e| wire::RpcError { code: wire::ErrorCode::Internal, message: e.to_string(), @@ -451,6 +451,7 @@ async fn dispatch( file, hunk_index, resolution, + path, } => { handle .send(Command::ResolveHunk { @@ -458,11 +459,16 @@ async fn dispatch( file, hunk_index, resolution, + path_override: path, }) .await; Ok(json!(null)) } - FileContents { task_id, path } => match handle.file_contents(&task_id, &path).await { + FileContents { + task_id, + path, + repo_path, + } => match handle.file_contents(&task_id, &path, repo_path).await { Some(doc) => serde_json::to_value(doc).map_err(|e| wire::RpcError { code: wire::ErrorCode::Internal, message: e.to_string(), @@ -476,8 +482,11 @@ async fn dispatch( task_id, project, include_ignored, + path, } => { - let files = handle.list_files(&task_id, project, include_ignored).await; + let files = handle + .list_files(&task_id, project, include_ignored, path) + .await; serde_json::to_value(files).map_err(|e| wire::RpcError { code: wire::ErrorCode::Internal, message: e.to_string(), @@ -487,12 +496,14 @@ async fn dispatch( task_id, path, content, + repo_path, } => { handle .send(Command::SaveFile { task_id, path, content, + path_override: repo_path, }) .await; Ok(json!(null)) @@ -502,9 +513,10 @@ async fn dispatch( message, files, amend, + path, } => { handle - .git_commit(&task_id, &message, files, amend) + .git_commit(&task_id, &message, files, amend, path) .await .map_err(|e| wire::RpcError { code: wire::ErrorCode::Internal, diff --git a/src/daemon/worktree.rs b/src/daemon/worktree.rs index 8e295ac..b1b8725 100644 --- a/src/daemon/worktree.rs +++ b/src/daemon/worktree.rs @@ -186,56 +186,97 @@ impl WorktreeManager { self.worktrees.values().collect() } - /// Discover existing warpforge worktrees on disk (for recovery after - /// daemon restart). + /// Discover existing git worktrees for this repo (for recovery after + /// daemon restart). Uses `git worktree list --porcelain` so worktrees + /// created outside warpforge (e.g. under `/tmp`) are picked up too, not + /// just those under `.worktrees/`. pub async fn discover(&mut self) -> Result<()> { - let wt_root = self.base_repo.join(".worktrees"); - if !wt_root.exists() { + let output = tokio::process::Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .current_dir(&self.base_repo) + .output() + .await + .context("failed to run git worktree list")?; + + if !output.status.success() { + // Not a git repo, or git unavailable — nothing to discover. return Ok(()); } - let mut entries = tokio::fs::read_dir(&wt_root) - .await - .context("reading .worktrees directory")?; + let stdout = String::from_utf8_lossy(&output.stdout); - while let Some(entry) = entries.next_entry().await? { - let path = entry.path(); - if !path.is_dir() { - continue; + // Canonicalize the base repo path so we can reliably skip the main + // worktree (git reports absolute, symlink-resolved paths). + let base_canon = tokio::fs::canonicalize(&self.base_repo) + .await + .unwrap_or_else(|_| self.base_repo.clone()); + + // Blocks are separated by blank lines. Fields: `worktree `, + // `HEAD `, `branch `, or bare `detached`. + for block in stdout.split("\n\n") { + let mut path: Option = None; + let mut branch: Option = None; + let mut detached = false; + + for line in block.lines() { + if let Some(p) = line.strip_prefix("worktree ") { + path = Some(PathBuf::from(p.trim())); + } else if let Some(b) = line.strip_prefix("branch ") { + // e.g. `refs/heads/warpforge/task/t_abc` -> keep short name. + let short = b.trim().strip_prefix("refs/heads/").unwrap_or(b.trim()); + branch = Some(short.to_string()); + } else if line.trim() == "detached" { + detached = true; + } } - let task_id = match path.file_name().and_then(|n| n.to_str()) { - Some(id) => id.to_string(), + + let path = match path { + Some(p) => p, None => continue, }; - // Verify it's a valid git worktree. - let head = path.join(".git"); - if !head.exists() { + // Skip the base repo's own worktree. + let path_canon = tokio::fs::canonicalize(&path) + .await + .unwrap_or_else(|_| path.clone()); + if path_canon == base_canon { continue; } - let branch = tokio::process::Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(&path) - .output() - .await - .ok() - .and_then(|o| { - if o.status.success() { - String::from_utf8(o.stdout) - .ok() - .map(|s| s.trim().to_string()) - } else { - None - } - }) - .unwrap_or_else(|| "unknown".to_string()); + let branch = if detached { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("detached"); + format!("{name} (detached)") + } else { + branch.clone().unwrap_or_else(|| "unknown".to_string()) + }; + + // Derive task_id + base_branch. Warpforge worktrees live under + // `.worktrees/`; anything else is treated as external. + let (task_id, base_branch) = if is_warpforge_worktree(&path) { + let id = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| external_task_id(&path, &branch, detached)); + (id, "main".to_string()) // best guess on discovery + } else if !detached { + // External worktree with a branch — key it by branch name. + (format!("external:{branch}"), "unknown".to_string()) + } else { + ( + external_task_id(&path, &branch, detached), + "unknown".to_string(), + ) + }; let wt = Worktree { task_id: task_id.clone(), path, - branch: branch.clone(), - base_branch: "main".to_string(), // best guess on discovery + branch, + base_branch, }; self.worktrees.insert(task_id, wt); } @@ -243,6 +284,23 @@ impl WorktreeManager { } } +/// Whether `path` is a warpforge-managed worktree (lives under `.worktrees/`). +fn is_warpforge_worktree(path: &Path) -> bool { + path.components().any(|c| c.as_os_str() == ".worktrees") +} + +/// Stable `external:` id for a worktree with no warpforge task, hashing +/// the path so the same worktree keeps the same id across daemon restarts. +fn external_task_id(path: &Path, _branch: &str, _detached: bool) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let hash = hasher.finish(); + format!("external:{:08x}", hash & 0xffff_ffff) +} + #[derive(Debug)] pub enum MergeResult { Ok { branch: String }, From d1c0f50a1bb23fa951a9832c992a3b688eca9abf Mon Sep 17 00:00:00 2001 From: Ihor Kolobanov Date: Sat, 25 Jul 2026 09:49:23 +0200 Subject: [PATCH 2/2] fix: tolerate startup discovery event before TaskCreated in test Events from startup config discovery (e.g. ProjectConfigChanged) can arrive before TaskCreated. The test now loops past unexpected events instead of panicking. --- src/daemon/mod.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index d56ea20..89b2983 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -69,19 +69,21 @@ mod tests { // The TaskCreated event carries a task whose session_id is None and // whose session identifier is NOT the task id — they are separate. - let ev = timeout(Duration::from_secs(1), events.recv()) - .await - .expect("event within 1s") - .expect("event"); - match ev { - Event::TaskCreated(task) => { - assert_eq!(task.id, id); - assert_eq!(task.session_id, None); - assert_eq!(task.status, TaskStatus::Queued); - assert_eq!(task.prompt, "fix the bug"); + // Startup config discovery can emit a ProjectConfigChanged first, so + // skip anything that isn't the TaskCreated we're asserting on. + let task = loop { + let ev = timeout(Duration::from_secs(1), events.recv()) + .await + .expect("event within 1s") + .expect("event"); + if let Event::TaskCreated(task) = ev { + break task; } - _ => panic!("expected TaskCreated"), - } + }; + assert_eq!(task.id, id); + assert_eq!(task.session_id, None); + assert_eq!(task.status, TaskStatus::Queued); + assert_eq!(task.prompt, "fix the bug"); let tasks = daemon.tasks().await; assert_eq!(tasks.len(), 1);