diff --git a/.agents/faber.json b/.agents/faber.json new file mode 100644 index 0000000..bc7ce0a --- /dev/null +++ b/.agents/faber.json @@ -0,0 +1,67 @@ +{ + "defaultAgent": "claude-code", + "defaultModel": null, + "defaultTransport": "acp", + "branchNamingPattern": "feat/{{task_id}}-{{task_slug}}", + "instructionFilePath": null, + "worktreeAutoCleanup": true, + "taskFilesToDisk": false, + "github": { + "syncEnabled": false, + "autoClose": false, + "autoReopen": false, + "prClosesRef": true, + "labelSync": false, + "labelMapping": {}, + "mergeDetection": true, + "syncDefaults": { + "title": false, + "body": false, + "status": false, + "labels": false + } + }, + "acp": { + "trustModePolicy": "auto_approve", + "defaultPolicy": "ask", + "permissionTimeout": 120, + "rules": [ + { + "capability": "fs_read", + "action": "auto_approve" + }, + { + "capability": "fs_write", + "action": "auto_approve" + }, + { + "capability": "terminal", + "action": "auto_approve" + }, + { + "capability": "Always Allow", + "action": "auto_approve" + } + ] + }, + "priorities": [ + { + "id": "P0", + "label": "Critical", + "color": "red", + "order": 0 + }, + { + "id": "P1", + "label": "High", + "color": "amber", + "order": 1 + }, + { + "id": "P2", + "label": "Normal", + "color": "gray", + "order": 2 + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 53bec7a..705b0aa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ Thumbs.db .mcp.json .codex .cursor -.agents +.agents/tasks .claude .gemini ~ diff --git a/CLAUDE.md b/CLAUDE.md index c16c9ab..4c457d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Faber is a cross-platform desktop app (Tauri 2 + React + TypeScript + Rust) for orchestrating AI coding agents. It wraps CLI-based agents (Claude Code, Codex CLI, Copilot CLI, Cursor Agent, Gemini CLI, OpenCode) with a task-driven workflow: Kanban board, git worktree isolation per task, PTY terminal sessions, multi-pane session grid, GitHub integration, and continuous mode for auto-launching task queues. +Faber is a cross-platform desktop app (Tauri 2 + React + TypeScript + Rust) for orchestrating AI coding agents. It wraps CLI-based agents (Claude Code, Codex CLI, Copilot CLI, Cursor Agent, Gemini CLI, OpenCode) with a task-driven workflow: Kanban board, git worktree isolation per task, PTY terminal sessions, multi-pane session grid, GitHub integration, and queue mode for auto-launching task queues. **ALWAYS** use the frontend skill when designing, developing or updating frontend components or pages. @@ -43,7 +43,7 @@ No frontend test runner is configured yet. Rust tests are inline (`#[cfg(test)]` - **State**: Zustand stores — primary `appStore.ts` and `updateStore.ts`. `ThemeContext` manages 4 themes (dark/light x glass/flat). - **Views** (`ViewId`): `dashboard`, `sessions`, `task-detail`, `review`, `github`, `skills-rules`, `help` - **Component tree**: `ThemeProvider -> StoreInitializer -> App -> AppShell` — AppShell is a 2-column CSS Grid. Sessions view stays mounted (hidden via CSS); other views mount/unmount. -- **IPC**: `invoke("command_name", { args })` for calls, `listen("event-name")` for async events (PTY output, session status, MCP updates, continuous mode) +- **IPC**: `invoke("command_name", { args })` for calls, `listen("event-name")` for async events (PTY output, session status, MCP updates, queue mode) ### Backend (`src-tauri/src/`) - **Database** (`db/`): SQLite with WAL mode, migrations in `db/migrations.rs`. IDs: `__`. @@ -53,7 +53,7 @@ No frontend test runner is configured yet. Rust tests are inline (`#[cfg(test)]` - **PTY** (`pty.rs`): Spawns pseudo-terminals via `portable-pty`, streams output via Tauri events. - **Agent adapters** (`agent/`): Detects installed CLI agents, maps to commands + default models. - **MCP server** (`mcp/`): Embedded HTTP server (axum) on `127.0.0.1:`. Sidecar binary (`bin/faber-mcp.rs`) acts as stdio-to-HTTP bridge for agent MCP configs. -- **Continuous Mode** (`continuous.rs`): Auto-launches a queue of ready tasks. Two branching strategies: `independent` and `chained`. +- **Queue mode** (`queue.rs`): Auto-launches a queue of ready tasks. Two branching strategies: `independent` and `chained`. - **Project config** (`project_config.rs`): File-based project settings via `.agents/faber.json`. See [Settings Architecture](#settings-architecture). - **Config watcher** (`config_watcher.rs`): Watches `.agents/faber.json` for external edits, re-syncs DB. @@ -159,7 +159,7 @@ error!(session_id = %id, error = %e, "PTY spawn failed"); Custom `AppError` enum with `From` conversions. All commands return `Result`. ### State (Rust) -Mutex-wrapped state (`PtyState`, `DbState`) for thread safety. MCP state uses `Arc>`. Continuous mode uses `Arc>`. +Mutex-wrapped state (`PtyState`, `DbState`) for thread safety. MCP state uses `Arc>`. Queue mode uses `Arc>`. ### State management (Frontend) - **Selectors**: Always use `useAppStore((s) => s.fieldName)` — never subscribe to the whole store. @@ -174,13 +174,5 @@ Strict mode enabled (`noUnusedLocals`, `noUnusedParameters`). Functional compone ## Faber Integration -You have MCP tools provided by the Faber IDE for reporting your progress. You MUST use them throughout your workflow. - -## Status Reporting (required) - -- `report_status(status, message, activity?)` — Call FIRST when you start working (status: "working"). Call again when your activity changes. Activity options: "researching", "exploring", "planning", "coding", "testing", "debugging", "reviewing". -- `report_progress(current_step, total_steps, description)` — Call before each major step so the IDE shows a progress bar. -- `report_files_changed(files)` — Call after modifying files so the IDE can track changes. -- `report_error(error, details?)` — Call if you hit a hard blocker (build failure, missing dependency, etc.). After calling this, STOP and wait for the user. -- `report_waiting(question)` — Call if you need user input or a decision. After calling this, STOP and wait — the session pauses until the user responds. +You have MCP tools provided by the Faber IDE. IMPORTANT: Call the `get_instructions` MCP tool FIRST before doing any work. It provides your session-specific workflow, available tools, and task context. diff --git a/README.md b/README.md index cebccf1..797e86d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

A cross-platform desktop app for orchestrating AI coding agents.
- Wraps CLI-based agents with a task-driven workflow: Kanban board, git worktree isolation, multi-pane terminal sessions, GitHub integration, skills & rules management, and continuous mode. + Wraps CLI-based agents with a task-driven workflow: Kanban board, git worktree isolation, multi-pane terminal sessions, GitHub integration, skills & rules management, and queue mode.

--- @@ -52,7 +52,7 @@ Download the latest release for your platform from the [Releases page](https://g - **Git worktree isolation** — each task runs in its own worktree and branch, so multiple agents can work in parallel without conflicts - **Multi-pane session grid** — run multiple agent sessions side-by-side with drag-and-drop layout and resizable panes - **Four session modes** — Task (structured implementation), Research (explore & plan), Vibe (freeform coding), Shell (raw terminal) -- **Continuous mode** — auto-launch a queue of ready tasks with independent or chained branching strategies +- **Queue mode** — auto-launch a queue of ready tasks with independent or chained branching strategies - **Prompt templates & quick actions** — configurable prompt templates with `{{variable}}` interpolation for all session types, plus one-click Quick Action buttons on session panes - **Skills & rules** — install and manage agent skills and project rules to extend agent capabilities - **GitHub integration** — issue import, PR creation, commit graph visualization, and label sync diff --git a/docs/acp_permissions.md b/docs/acp_permissions.md index 2bd49aa..11fc4ee 100644 --- a/docs/acp_permissions.md +++ b/docs/acp_permissions.md @@ -60,11 +60,11 @@ The fallback action when no rule matches a request: ### Trust Mode -Controls permission behavior during **autonomous operation** (continuous mode, auto-launched task queues): +Controls permission behavior during **autonomous operation** (queue mode, auto-launched task queues): - **Auto-approve all** — No permission dialogs when running autonomously. - **Use normal rules** — Apply the same rule set as interactive sessions. -- **Deny write operations** — Allow reads but block all writes in autonomous mode. Useful for safe continuous runs. +- **Deny write operations** — Allow reads but block all writes in autonomous mode. Useful for safe queue runs. ### Permission Timeout diff --git a/docs/continuous_mode.md b/docs/continuous_mode.md deleted file mode 100644 index cfe0b46..0000000 --- a/docs/continuous_mode.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -title: Continuous Mode -description: Queue tasks and run them sequentially with automatic handoff -icon: list-checks -order: 4 ---- - -# Continuous Mode - -Continuous Mode lets you queue multiple tasks and run them sequentially with automatic handoff between agents. When one task finishes, the next one starts automatically — no manual intervention needed. - ---- - -## Quick Start - -1. Move 2 or more tasks to **Ready** status on the Kanban board -2. Click the **Continuous** button in the Dashboard toolbar -3. Select and order your tasks in the queue -4. Choose a branching strategy and agent -5. Click **Start** - -The first task launches immediately. When the agent calls `report_complete`, Faber marks the task as **In Review** and automatically launches the next task in the queue. The completed session stays open so you can review the agent's output. - ---- - -## The Launch Dialog - -### Task Queue - -All tasks in **Ready** status are listed with checkboxes. You can: - -- **Uncheck** tasks you don't want to include (minimum 2 required) -- **Reorder** tasks using the up/down arrows -- See **priority badges** and **dependency counts** for each task - -### Branching Strategy - -| Strategy | Behavior | -|---|---| -| **Independent** | Each task gets its own branch from the base branch. Tasks are isolated from each other. Best for unrelated work. | -| **Chained** | Each task branches from the previous task's branch. Changes accumulate. Best for sequential work where later tasks build on earlier ones. | - -### Smart Strategy Suggestion - -If your tasks have `depends_on` relationships (set manually or detected during [GitHub import](/help/github_workflow)), Faber analyzes the dependency graph and: - -- **Auto-suggests Chained** when tasks have dependency links -- **Auto-sorts** tasks in dependency order (dependencies run first) -- Shows an info banner explaining the detected relationships - -You can always override the suggestion by clicking the other strategy. - -### Agent & Model - -Select which AI agent to use for all tasks in the queue. You can optionally override the default model. The agent selection works the same as the regular session launcher. - -### Base Branch - -Choose which branch to create task branches from. Defaults to the current HEAD. - ---- - -## Status Bar - -While Continuous Mode is active, a status bar appears at the top of the Dashboard view showing: - -- **Progress**: "Task 2/5 — [task title]" with a visual progress bar -- **Status indicator**: Green (running), Yellow (paused), Red (error) -- **Controls**: Pause, Resume, and Stop buttons - ---- - -## How It Works - -### Normal Flow - -``` -Task 1 (running) → agent completes → mark "in-review" → stop session - ↓ -Task 2 (running) → agent completes → mark "in-review" → stop session - ↓ -Task 3 (running) → agent completes → mark "in-review" → stop session - ↓ -All done — continuous mode finishes -``` - -Each task transition includes a 2-second delay to let the agent's terminal finish writing output before the session is stopped. - -### Pausing - -Click **Pause** to prevent auto-advancement. The currently running agent session continues working, but when it finishes, the next task will not start automatically. Click **Resume** to continue the queue. - -If the agent finishes while paused, Faber remembers this — resuming will immediately advance to the next task. - -### Stopping - -Click **Stop** to end continuous mode entirely. The currently running session is terminated and the queue is cleared. - -### Error Handling - -If an agent crashes (PTY exits without calling `report_complete`), Faber: - -1. Marks the current queue item as **Error** -2. Pauses the continuous run -3. Shows the error in the status bar - -You can then investigate, fix the issue, and resume to continue with the next task. - -If you manually stop a session that's part of a continuous run, the run is paused (not stopped). This lets you restart from where you left off. - ---- - -## Branching Strategies in Detail - -### Independent - -``` -main ──┬── feat/T-001-auth ──── (task 1 work) - ├── feat/T-002-api ───── (task 2 work) - └── feat/T-003-ui ────── (task 3 work) -``` - -Each task gets a clean branch from the base. No task can see another task's changes. This is ideal when tasks are unrelated and can be reviewed/merged independently. - -### Chained - -``` -main ── feat/T-001-auth ── feat/T-002-api ── feat/T-003-ui - (task 1 work) (task 2 work) (task 3 work) -``` - -Each task branches from the previous task's branch. Later tasks can see and build on earlier changes. This is ideal when tasks form a sequence — for example, "set up auth" → "build API endpoints using auth" → "build UI using the API". - ---- - -## Task Dependencies & Ordering - -Tasks can declare dependencies via the `depends_on` field in their task file frontmatter: - -```yaml ---- -id: T-003 -title: Build UI components -depends_on: - - T-001 - - T-002 ---- -``` - -Dependencies can also be **auto-detected** when importing GitHub issues. If an issue body contains patterns like "depends on #42" or "blocked by #15", Faber resolves these to local task IDs during import. - -When you open the Continuous Mode dialog with tasks that have dependencies: - -1. The strategy is auto-set to **Chained** -2. Tasks are auto-sorted so dependencies run before dependents -3. An info banner shows the detected dependency links - ---- - -## Requirements - -- Tasks must be in **Ready** status to appear in the queue -- Minimum 2 tasks required -- At least one agent must be installed -- The project must be a Git repository (for worktree/branch creation) diff --git a/docs/general.md b/docs/general.md index bdf17b9..ba69c47 100644 --- a/docs/general.md +++ b/docs/general.md @@ -229,7 +229,7 @@ Settings are organized into **App** (global) and **Project** (per-project) secti Manage prompt templates and quick actions: -- **Session Prompts** — Default prompts used when launching task, research, continuous, and task-continue sessions. Each template supports `{{variable}}` interpolation (e.g., `{{task_id}}`, `{{worktree_hint}}`). Session prompts are protected (cannot be deleted) but fully customizable. +- **Session Prompts** — Default prompts used when launching task, research, queue, and task-continue sessions. Each template supports `{{variable}}` interpolation (e.g., `{{task_id}}`, `{{worktree_hint}}`). Session prompts are protected (cannot be deleted) but fully customizable. - **Quick Actions** — Action buttons that appear on active session panes when you hover over them. Click a quick action to send the prompt directly to the agent. Built-in actions include "Commit", "Fix Errors", and "Summarize". You can add, edit, and delete custom actions with configurable labels, icons, and prompts. - **Reset to Defaults** — Restore all templates and actions to their built-in defaults. diff --git a/docs/github_workflow.md b/docs/github_workflow.md index c35113e..43140c1 100644 --- a/docs/github_workflow.md +++ b/docs/github_workflow.md @@ -55,7 +55,7 @@ Cross-repository references are also supported: `depends on other/repo#99`. If the referenced issue has already been imported (or is being imported in the same batch), Faber resolves the reference to a local task ID and populates the task's `depends_on` field automatically. Unresolved references (issues not imported) are silently skipped. -These dependency relationships are used by **Continuous Mode** to suggest a branching strategy and automatically sort the task queue. See the [Continuous Mode](/help/continuous_mode) documentation for details. +These dependency relationships are used by **Queue Mode** to suggest a branching strategy and automatically sort the task queue. See the [Queue Mode](/help/queue_mode) documentation for details. --- @@ -65,7 +65,7 @@ The shared toolbar at the top of the Git view provides direct access to common g ### Pull & Push -- **Pull** — Fetches from origin and fast-forwards the current branch. If the working tree has uncommitted changes or the branch has diverged (cannot fast-forward), Pull will show an error. Commit or stash your changes first. +- **Pull** — Fetches from origin and fast-forwards the current branch. Works with uncommitted changes as long as they don't conflict with incoming changes (matching VS Code / Zed behavior). If the branch has diverged (cannot fast-forward), Pull will show an error. - **Push** — Pushes the current branch to origin. Uses `gh auth git-credential` for authentication, so you only need `gh auth login` once. Both buttons show ahead/behind badges when your local branch differs from the remote. These counts are refreshed automatically when you open the Git view and after each operation. diff --git a/docs/queue_mode.md b/docs/queue_mode.md new file mode 100644 index 0000000..712e902 --- /dev/null +++ b/docs/queue_mode.md @@ -0,0 +1,176 @@ +--- +title: Queue Mode +description: Run multiple tasks with automatic handoff and dependency orchestration +icon: list-checks +order: 4 +--- + +# Queue Mode + +Queue Mode lets you run multiple tasks with automatic handoff between agents. Tasks can run in parallel (Independent) or with dependency-aware orchestration that auto-merges completed work into a shared branch. + +--- + +## Quick Start + +1. Move 2 or more tasks to **Ready** status on the Kanban board +2. Click the **Queue** button in the Dashboard toolbar +3. Select and order your tasks in the queue +4. Choose a branching strategy and agent +5. Click **Start** + +The first task launches immediately. When the agent calls `report_complete`, Faber marks the task as **In Review** and automatically launches the next task in the queue. The completed session stays open so you can review the agent's output. + +--- + +## The Launch Dialog + +### Strategy + +Choose how tasks are executed and how branches are managed: + +| Strategy | Behavior | +|---|---| +| **Independent** | Each task gets its own branch from the base branch. All tasks run in parallel. No auto-merge — you manage merge ordering. Best for unrelated work. | +| **Orchestrated** | Dependency-aware execution with auto-merge. Tasks launch when their dependencies complete, and finished work is automatically merged into a shared integration branch. Best for related work with dependencies. | + +### Task Queue + +The task list adapts based on your chosen strategy: + +- **Independent**: A flat, reorderable list. Use checkboxes to include/exclude tasks and arrows to reorder. +- **Orchestrated**: Tasks are grouped into **execution phases**. Phase 1 contains tasks with no dependencies (they run in parallel). Phase 2 contains tasks that depend on Phase 1, and so on. Each phase starts only after the previous phase completes. + +Both views show **priority badges**, **dependency counts**, and **per-task agent overrides**. + +### Smart Strategy Suggestion + +If your tasks have `depends_on` relationships (set manually or detected during [GitHub import](/help/github_workflow)), Faber analyzes the dependency graph and: + +- **Auto-suggests Orchestrated** when tasks have dependency links +- **Auto-sorts** tasks in dependency order (dependencies run first) +- Shows an info banner explaining the detected relationships + +You can always override the suggestion by clicking the other strategy. + +### Agent & Model + +Select which AI agent to use for all tasks in the queue. You can optionally override the default model. The agent selection works the same as the regular session launcher. + +### Base Branch + +Choose which branch to create task branches from. Defaults to the current HEAD. + +--- + +## Status Bar + +While Queue Mode is active, a status bar appears at the top of the Dashboard view showing: + +- **Progress**: "Task 2/5 — [task title]" with a visual progress bar +- **Status indicator**: Green (running), Yellow (paused), Red (error) +- **Controls**: Pause, Resume, and Stop buttons + +--- + +## How It Works + +### Normal Flow + +**Independent**: All tasks launch in parallel. As each agent calls `report_complete`, its task moves to **In Review**. The queue finishes when all tasks are done. + +**Orchestrated**: Root tasks (no dependencies) launch first. When an agent completes, its branch is auto-merged into the integration branch, the task moves to **In Review**, and any newly-unblocked tasks in the next phase launch automatically. + +``` +Phase 1 tasks launch → agents complete → merge to integration branch + ↓ +Phase 2 tasks launch (deps satisfied) → agents complete → merge + ↓ +All phases done — queue mode finishes +``` + +Each task transition includes a 2-second delay to let the agent's terminal finish writing output before the session is stopped. + +### Pausing + +Click **Pause** to prevent auto-advancement. The currently running agent session continues working, but when it finishes, the next task will not start automatically. Click **Resume** to continue the queue. + +If the agent finishes while paused, Faber remembers this — resuming will immediately advance to the next task. + +### Stopping + +Click **Stop** to end queue mode entirely. The currently running session is terminated and the queue is cleared. + +### Error Handling + +If an agent crashes (PTY exits without calling `report_complete`), Faber: + +1. Marks the current queue item as **Error** +2. Pauses the queue run +3. Shows the error in the status bar + +You can then investigate, fix the issue, and resume to continue with the next task. + +If you manually stop a session that's part of a queue run, the run is paused (not stopped). This lets you restart from where you left off. + +--- + +## Strategies in Detail + +### Independent + +``` +main ──┬── feat/T-001-auth ──── (task 1 work) + ├── feat/T-002-api ───── (task 2 work) + └── feat/T-003-ui ────── (task 3 work) +``` + +Each task gets a clean branch from the base. All tasks launch in parallel. No auto-merge — you review and merge each branch independently. Ideal when tasks are unrelated. + +### Orchestrated + +``` +main ── queue/qr_abc123 (integration branch) + ↑ merge T-001 ↑ merge T-002 ↑ merge T-003 + +Phase 1: T-001 (no deps) ← runs immediately +Phase 2: T-002 (depends on T-001) ← runs after Phase 1 completes +Phase 3: T-003 (depends on T-002) ← runs after Phase 2 completes +``` + +Tasks are grouped into phases based on their dependency graph. Within each phase, tasks with no mutual dependencies run in parallel. After each task completes, its branch is automatically merged into a shared **integration branch** (`queue/`). Later phases branch from this integration branch, so they can see all previously merged work. + +If a merge conflict occurs, the queue pauses and you can resolve it manually, then either **retry the merge** or **skip** the conflicted task. + +--- + +## Task Dependencies & Ordering + +Tasks can declare dependencies via the `depends_on` field in their task file frontmatter: + +```yaml +--- +id: T-003 +title: Build UI components +depends_on: + - T-001 + - T-002 +--- +``` + +Dependencies can also be **auto-detected** when importing GitHub issues. If an issue body contains patterns like "depends on #42" or "blocked by #15", Faber resolves these to local task IDs during import. + +When you open the Queue Mode dialog with tasks that have dependencies: + +1. The strategy is auto-set to **Orchestrated** +2. Tasks are grouped into execution phases based on the dependency graph +3. An info banner shows the detected dependency links + +--- + +## Requirements + +- Tasks must be in **Ready** status to appear in the queue +- Minimum 2 tasks required +- At least one agent must be installed +- The project must be a Git repository (for worktree/branch creation) diff --git a/docs/supported_agents.md b/docs/supported_agents.md index 7be156b..6734342 100644 --- a/docs/supported_agents.md +++ b/docs/supported_agents.md @@ -156,7 +156,7 @@ Agents can call these tools to communicate with Faber. The tools available depen | Tool | Available in | Purpose | |---|---|---| -| `report_complete` | Task, Continuous | Signal that the task is fully done. Moves the task to **In Review**. In continuous mode, auto-launches the next task. | +| `report_complete` | Task, Queue | Signal that the task is fully done. Moves the task to **In Review**. In queue mode, auto-launches the next task. | | `report_researched` | Research | Signal that research is complete. The user is prompted to continue to implementation. May move the task from Backlog to Ready. | Breakdown, Vibe, and Chat sessions have no completion tool — the user drives the lifecycle. diff --git a/package.json b/package.json index 19e3f48..5c9fcc7 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "diff2html": "^3.4.56", + "diff": "^8.0.4", "dompurify": "^3.3.1", "embla-carousel-react": "^8.6.0", "lucide-react": "^0.564.0", @@ -61,7 +61,6 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.10.0", - "@types/dompurify": "^3.2.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 483269c..251c39b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,9 +92,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - diff2html: - specifier: ^3.4.56 - version: 3.4.56 + diff: + specifier: ^8.0.4 + version: 8.0.4 dompurify: specifier: ^3.3.1 version: 3.3.1 @@ -147,9 +147,6 @@ importers: '@tauri-apps/cli': specifier: ^2.10.0 version: 2.10.0 - '@types/dompurify': - specifier: ^3.2.0 - version: 3.2.0 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -748,10 +745,6 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@profoundlogic/hogan@3.0.4': - resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==} - hasBin: true - '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -1462,10 +1455,6 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - '@types/dompurify@3.2.0': - resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} - deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. - '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1566,9 +1555,6 @@ packages: '@xyflow/system@0.0.75': resolution: {integrity: sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==} - abbrev@1.1.1: - resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} - accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2062,12 +2048,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - diff2html@3.4.56: - resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==} - engines: {node: '>=12'} - - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} dompurify@3.3.1: @@ -2390,10 +2372,6 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} - hono@4.12.3: resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} @@ -3007,10 +2985,6 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - nopt@1.0.10: - resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==} - hasBin: true - npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4385,10 +4359,6 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@profoundlogic/hogan@3.0.4': - dependencies: - nopt: 1.0.10 - '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': @@ -5040,10 +5010,6 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/dompurify@3.2.0': - dependencies: - dompurify: 3.3.1 - '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5155,8 +5121,6 @@ snapshots: d3-selection: 3.0.0 d3-zoom: 3.0.0 - abbrev@1.1.1: {} - accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -5628,14 +5592,7 @@ snapshots: dependencies: dequal: 2.0.3 - diff2html@3.4.56: - dependencies: - '@profoundlogic/hogan': 3.0.4 - diff: 8.0.3 - optionalDependencies: - highlight.js: 11.11.1 - - diff@8.0.3: {} + diff@8.0.4: {} dompurify@3.3.1: optionalDependencies: @@ -6072,9 +6029,6 @@ snapshots: headers-polyfill@4.0.3: {} - highlight.js@11.11.1: - optional: true - hono@4.12.3: {} html-url-attributes@3.0.1: {} @@ -6843,10 +6797,6 @@ snapshots: node-releases@2.0.27: {} - nopt@1.0.10: - dependencies: - abbrev: 1.1.1 - npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -7296,7 +7246,7 @@ snapshots: cosmiconfig: 9.0.1(typescript@5.7.3) dedent: 1.7.2 deepmerge: 4.3.1 - diff: 8.0.3 + diff: 8.0.4 execa: 9.6.1 fast-glob: 3.3.3 fs-extra: 11.3.3 diff --git a/src-tauri/src/acp/permissions.rs b/src-tauri/src/acp/permissions.rs index db8fac3..dfb57e7 100644 --- a/src-tauri/src/acp/permissions.rs +++ b/src-tauri/src/acp/permissions.rs @@ -135,7 +135,7 @@ pub struct PermissionContext { pub capability: CapabilityType, /// File path (for fs_read/fs_write) or command string (for terminal). pub detail: String, - /// Whether the session is running in trust mode (e.g. continuous mode auto-launch). + /// Whether the session is running in trust mode (e.g. queue mode auto-launch). /// When true, the trust mode policy overrides normal rule evaluation. pub is_trust_mode: bool, } @@ -400,7 +400,7 @@ fn get_project_default_policy(conn: &Connection, project_id: &str) -> Option Option { db::settings::get_resolved(conn, project_id, "acp_trust_mode_policy").ok().flatten() } diff --git a/src-tauri/src/acp/state.rs b/src-tauri/src/acp/state.rs index eae7642..70eb0c1 100644 --- a/src-tauri/src/acp/state.rs +++ b/src-tauri/src/acp/state.rs @@ -34,7 +34,7 @@ pub struct AcpSessionState { /// /// Wrapped in `Arc>` for thread-safe access from Tauri commands /// and async handlers, matching the pattern used by `McpState` and -/// `ContinuousState`. +/// `QueueState`. pub type AcpState = Arc>>; /// Create a new empty ACP state for Tauri managed state registration. diff --git a/src-tauri/src/commands/continuous.rs b/src-tauri/src/commands/continuous.rs deleted file mode 100644 index 00be3e9..0000000 --- a/src-tauri/src/commands/continuous.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tauri::{AppHandle, Emitter, Manager, State}; -use tokio::sync::Mutex as TokioMutex; - -use crate::acp::state::AcpState; -use crate::commands::prompts; -use crate::continuous::{ - self, BranchingStrategy, ContinuousModeUpdate, ContinuousQueueItem, ContinuousRun, - ContinuousState, ContinuousStatus, QueueItemStatus, -}; -use crate::db; -use crate::db::models::SessionTransport; -use crate::db::DbState; -use crate::error::AppError; -use crate::mcp::McpState; -use crate::pty::PtyState; -use crate::session; - -#[tauri::command] -#[allow(clippy::too_many_arguments)] -pub fn start_continuous_mode( - db: State<'_, DbState>, - pty: State<'_, PtyState>, - mcp: State<'_, Arc>>, - acp: State<'_, AcpState>, - cont: State<'_, ContinuousState>, - app: AppHandle, - project_id: String, - task_ids: Vec, - strategy: String, - base_branch: Option, - agent_name: Option, - model: Option, - transport: Option, -) -> Result { - let transport = match transport.as_deref() { - Some("acp") => SessionTransport::Acp, - _ => SessionTransport::Pty, - }; - if task_ids.len() < 2 { - return Err(AppError::Validation( - "Continuous mode requires at least 2 tasks".into(), - )); - } - - let strategy = match strategy.as_str() { - "chained" => BranchingStrategy::Chained, - _ => BranchingStrategy::Independent, - }; - - // Check if there's already an active run for this project - { - let guard = cont.blocking_lock(); - if guard.contains_key(&project_id) { - return Err(AppError::Validation( - "Continuous mode is already active for this project. Dismiss or stop it first.".into(), - )); - } - } - - // Get MCP port BEFORE acquiring DB lock to avoid nested mutex contention - let mcp_port = session::get_mcp_port(&mcp); - - // Validate all tasks exist and are in "ready" status, resolve per-task agent - let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; - let mut queue: Vec = Vec::with_capacity(task_ids.len()); - for tid in &task_ids { - let task = db::tasks::get(&conn, tid, &project_id)? - .ok_or_else(|| AppError::NotFound(format!("Task {tid}")))?; - if task.status.as_str() != "ready" { - return Err(AppError::Validation(format!( - "Task {} is not in 'ready' status (current: {})", - tid, task.status - ))); - } - // Per-task agent: task.agent overrides run-level agent_name - let resolved_agent = task.agent.or_else(|| agent_name.clone()); - queue.push(ContinuousQueueItem { - task_id: tid.clone(), - status: QueueItemStatus::Pending, - session_id: None, - error: None, - agent_name: resolved_agent, - }); - } - - let mut last_branch: Option = None; - - // Load continuous mode template once for all tasks - let cont_template = prompts::get_session_prompt(&conn, "continuous"); - - match strategy { - BranchingStrategy::Independent => { - // Launch ALL tasks in parallel — each gets its own session from base branch - for (i, tid) in task_ids.iter().enumerate() { - queue[i].status = QueueItemStatus::Running; - let task_agent = queue[i].agent_name.as_deref(); - - let mut vars = HashMap::new(); - vars.insert("task_id", tid.as_str()); - vars.insert("mode", "parallel"); - let user_prompt = Some(session::interpolate_vars(&cont_template.prompt, &vars)); - - // Each MCP port lookup needs to be fresh for each session - let port = session::get_mcp_port(&mcp); - let result = match transport { - SessionTransport::Acp => { - let opts = session::AcpTaskSessionOpts { - task_id: tid, - agent_name: task_agent, - model: model.as_deref(), - create_worktree: true, - base_branch: base_branch.as_deref(), - user_prompt: user_prompt.as_deref(), - is_trust_mode: true, - }; - session::start_acp_task_session(&conn, &app, &mcp, &acp, port, &project_id, &opts) - } - SessionTransport::Pty => { - session::start_task_session( - &conn, &pty, &app, &mcp, port, &project_id, - tid, task_agent, model.as_deref(), - true, base_branch.as_deref(), user_prompt.as_deref(), - ) - } - }; - match result { - Ok(session) => { - queue[i].session_id = Some(session.id.clone()); - } - Err(e) => { - tracing::error!(task_id = %tid, %e, "Failed to launch parallel session"); - queue[i].status = QueueItemStatus::Error; - queue[i].error = Some(format!("Launch failed: {e}")); - } - } - } - } - BranchingStrategy::Chained => { - // Sequential — launch only the first task - queue[0].status = QueueItemStatus::Running; - let first_agent = queue[0].agent_name.as_deref(); - - let first_task_id = &task_ids[0]; - let mut vars = HashMap::new(); - vars.insert("task_id", first_task_id.as_str()); - vars.insert("mode", "chained"); - let first_user_prompt = Some(session::interpolate_vars(&cont_template.prompt, &vars)); - - let first_session = match transport { - SessionTransport::Acp => { - let opts = session::AcpTaskSessionOpts { - task_id: first_task_id, - agent_name: first_agent, - model: model.as_deref(), - create_worktree: true, - base_branch: base_branch.as_deref(), - user_prompt: first_user_prompt.as_deref(), - is_trust_mode: true, - }; - session::start_acp_task_session(&conn, &app, &mcp, &acp, mcp_port, &project_id, &opts)? - } - SessionTransport::Pty => { - session::start_task_session( - &conn, &pty, &app, &mcp, mcp_port, &project_id, - first_task_id, first_agent, model.as_deref(), - true, base_branch.as_deref(), first_user_prompt.as_deref(), - )? - } - }; - - queue[0].session_id = Some(first_session.id.clone()); - - // Record the branch for chaining - last_branch = first_session - .worktree_path - .as_ref() - .and_then(|_| { - db::tasks::get(&conn, first_task_id, &project_id) - .ok() - .flatten() - .and_then(|t| t.branch) - }); - } - } - - let run = ContinuousRun { - project_id: project_id.clone(), - status: ContinuousStatus::Running, - queue, - current_index: 0, - strategy, - base_branch, - agent_name, - model, - last_branch, - transport, - }; - - // Store the run - { - let mut guard = cont.blocking_lock(); - guard.insert(project_id.clone(), run.clone()); - } - - tracing::info!( - project_id = %project_id, - strategy = ?run.strategy, - task_count = run.queue.len(), - agent = ?run.agent_name, - "Continuous mode started" - ); - - let _ = app.emit( - "continuous-mode-update", - ContinuousModeUpdate { - project_id, - run: run.clone(), - }, - ); - - Ok(run) -} - -#[tauri::command] -pub fn pause_continuous_mode( - cont: State<'_, ContinuousState>, - app: AppHandle, - project_id: String, -) -> Result { - let mut guard = cont.blocking_lock(); - let run = guard - .get_mut(&project_id) - .ok_or_else(|| AppError::NotFound("No active continuous run".into()))?; - - if run.status != ContinuousStatus::Running { - return Err(AppError::Validation( - "Continuous mode is not running".into(), - )); - } - - run.status = ContinuousStatus::Paused; - tracing::info!(project_id = %project_id, "Continuous mode paused"); - let result = run.clone(); - - let _ = app.emit( - "continuous-mode-update", - ContinuousModeUpdate { - project_id, - run: result.clone(), - }, - ); - - Ok(result) -} - -#[tauri::command] -pub fn resume_continuous_mode( - cont: State<'_, ContinuousState>, - app: AppHandle, - project_id: String, -) -> Result { - // First, set status to Running - { - let mut guard = cont.blocking_lock(); - let run = guard - .get_mut(&project_id) - .ok_or_else(|| AppError::NotFound("No active continuous run".into()))?; - - if run.status != ContinuousStatus::Paused { - return Err(AppError::Validation( - "Continuous mode is not paused".into(), - )); - } - - run.status = ContinuousStatus::Running; - tracing::info!(project_id = %project_id, "Continuous mode resumed"); - - let _ = app.emit( - "continuous-mode-update", - ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }, - ); - } - - // Check if the current task's session is already completed (MCP reported done while paused) - let should_advance = { - let guard = cont.blocking_lock(); - if let Some(run) = guard.get(&project_id) { - if let Some(item) = run.queue.get(run.current_index) { - if let Some(sid) = &item.session_id { - let mcp_state: tauri::State<'_, Arc>> = app.state(); - let mcp_guard = mcp_state.blocking_lock(); - mcp_guard - .sessions - .get(sid.as_str()) - .map(|d| d.completed) - .unwrap_or(false) - } else { - false - } - } else { - false - } - } else { - false - } - }; - - if should_advance { - continuous::try_advance(&app, &project_id)?; - } - - let guard = cont.blocking_lock(); - guard - .get(&project_id) - .cloned() - .ok_or_else(|| AppError::NotFound("Run completed during resume".into())) -} - -#[tauri::command] -pub fn stop_continuous_mode( - db: State<'_, DbState>, - pty: State<'_, PtyState>, - mcp: State<'_, Arc>>, - acp: State<'_, AcpState>, - cont: State<'_, ContinuousState>, - app: AppHandle, - project_id: String, -) -> Result<(), AppError> { - let mut guard = cont.blocking_lock(); - let run = match guard.remove(&project_id) { - Some(r) => r, - None => return Ok(()), // no active run - }; - drop(guard); - tracing::info!(project_id = %project_id, "Continuous mode stopped"); - - // Stop ALL currently running sessions (important for independent mode) - let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; - for item in &run.queue { - if item.status == QueueItemStatus::Running { - if let Some(sid) = &item.session_id { - let _ = session::stop_session(&conn, &pty, &app, &mcp, Some(&acp), sid); - } - } - } - - let completed_count = run.queue.iter() - .filter(|i| i.status == QueueItemStatus::Completed) - .count(); - - // Emit finished event so the frontend properly clears the state - let _ = app.emit( - "continuous-mode-finished", - continuous::ContinuousModeFinished { - project_id, - completed_count, - }, - ); - - Ok(()) -} - -/// Dismiss a completed continuous run — stops and removes all related sessions. -/// Called by the user from the continuous mode bar after reviewing agent output. -#[tauri::command] -pub fn dismiss_continuous_mode( - db: State<'_, DbState>, - pty: State<'_, PtyState>, - mcp: State<'_, Arc>>, - acp: State<'_, AcpState>, - cont: State<'_, ContinuousState>, - app: AppHandle, - project_id: String, -) -> Result<(), AppError> { - let mut guard = cont.blocking_lock(); - let run = match guard.remove(&project_id) { - Some(r) => r, - None => return Ok(()), // no active run - }; - drop(guard); - tracing::info!(project_id = %project_id, "Continuous mode dismissed"); - - // Stop and remove ALL sessions that are still alive - let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; - for item in &run.queue { - if let Some(sid) = &item.session_id { - // stop_and_remove handles already-stopped sessions gracefully - let _ = session::stop_and_remove_session(&conn, &pty, &app, &mcp, Some(&acp), sid); - } - } - - let completed_count = run - .queue - .iter() - .filter(|i| i.status == QueueItemStatus::Completed) - .count(); - - // Emit finished event to clear frontend state - let _ = app.emit( - "continuous-mode-finished", - continuous::ContinuousModeFinished { - project_id, - completed_count, - }, - ); - - Ok(()) -} - -#[tauri::command] -pub fn get_continuous_mode_status( - cont: State<'_, ContinuousState>, - project_id: String, -) -> Result, AppError> { - let guard = cont.blocking_lock(); - Ok(guard.get(&project_id).cloned()) -} diff --git a/src-tauri/src/commands/git.rs b/src-tauri/src/commands/git.rs index ba170dd..8557249 100644 --- a/src-tauri/src/commands/git.rs +++ b/src-tauri/src/commands/git.rs @@ -253,6 +253,60 @@ pub async fn unstage_file( .map_err(|e| AppError::Io(e.to_string()))? } +#[tauri::command] +pub async fn discard_file( + state: State<'_, DbState>, + project_id: String, + worktree_path: String, + file_path: String, +) -> Result<(), AppError> { + let project_path = get_project_path(&state, &project_id)?; + let validated_wt = validate_worktree_path(&project_path, &worktree_path)?; + tokio::task::spawn_blocking(move || git::discard_file(&validated_wt, &file_path)) + .await + .map_err(|e| AppError::Io(e.to_string()))? +} + +#[tauri::command] +pub async fn commit_amend( + state: State<'_, DbState>, + project_id: String, + worktree_path: String, + message: Option, +) -> Result { + let project_path = get_project_path(&state, &project_id)?; + let validated_wt = validate_worktree_path(&project_path, &worktree_path)?; + tokio::task::spawn_blocking(move || git::commit_amend(&validated_wt, message.as_deref())) + .await + .map_err(|e| AppError::Io(e.to_string()))? +} + +#[tauri::command] +pub async fn get_last_commit_message( + state: State<'_, DbState>, + project_id: String, + worktree_path: String, +) -> Result { + let project_path = get_project_path(&state, &project_id)?; + let validated_wt = validate_worktree_path(&project_path, &worktree_path)?; + tokio::task::spawn_blocking(move || git::get_last_commit_message(&validated_wt)) + .await + .map_err(|e| AppError::Io(e.to_string()))? +} + +#[tauri::command] +pub async fn get_staged_diff( + state: State<'_, DbState>, + project_id: String, + worktree_path: String, +) -> Result { + let project_path = get_project_path(&state, &project_id)?; + let validated_wt = validate_worktree_path(&project_path, &worktree_path)?; + tokio::task::spawn_blocking(move || git::get_staged_diff(&validated_wt)) + .await + .map_err(|e| AppError::Io(e.to_string()))? +} + #[tauri::command] pub async fn push_branch( state: State<'_, DbState>, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a3736cf..8b7618e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,6 @@ pub mod acp_permissions; pub mod agents; -pub mod continuous; +pub mod queue; pub mod docs; pub mod files; pub mod fonts; diff --git a/src-tauri/src/commands/prompts.rs b/src-tauri/src/commands/prompts.rs index 88ed95f..02b7397 100644 --- a/src-tauri/src/commands/prompts.rs +++ b/src-tauri/src/commands/prompts.rs @@ -43,7 +43,7 @@ fn builtin_templates() -> Vec { label: "Task Launch".into(), icon: "play".into(), prompt: "Start working on task {{task_id}}. \ - Use the `get_task` MCP tool to fetch the task details, \ + Use the `get_instructions` MCP tool to get your session instructions and task details, \ then begin implementing immediately. \ {{worktree_hint}}" .into(), @@ -58,8 +58,8 @@ fn builtin_templates() -> Vec { label: "Task Continue".into(), icon: "rotate-cw".into(), prompt: "Continue working on task {{task_id}}. \ - Use the `get_task` MCP tool to fetch the task details \ - and continue where you left off. \ + Use the `get_instructions` MCP tool to get your session instructions and task details, \ + then continue where you left off. \ {{worktree_hint}}" .into(), category: PromptCategory::Session, @@ -73,7 +73,7 @@ fn builtin_templates() -> Vec { label: "Research".into(), icon: "search".into(), prompt: "Task {{task_id}} needs to be analyzed and researched together with the user. \ - Start by using the `get_task` MCP tool to fetch the task details. \ + Start by calling the `get_instructions` MCP tool to get your session instructions and task details. \ The goal is to research the codebase, explore approaches, \ and then update the task file with a concrete implementation plan \ using the `update_task_plan` MCP tool. Ask the user for next steps." @@ -85,15 +85,15 @@ fn builtin_templates() -> Vec { sort_order: 2, }, PromptTemplate { - id: "continuous".into(), - label: "Continuous Mode".into(), + id: "queue".into(), + label: "Queue Mode".into(), icon: "zap".into(), - prompt: "You are running in continuous mode ({{mode}}). \ - Use the `get_task` MCP tool to fetch task {{task_id}} details, \ - then begin working on it autonomously." + prompt: "You are running in queue mode ({{mode}}). \ + Use the `get_instructions` MCP tool to get your session instructions and task details, \ + then begin working on task {{task_id}} autonomously." .into(), category: PromptCategory::Session, - session_mode: Some("continuous".into()), + session_mode: Some("queue".into()), quick_action: false, builtin: true, sort_order: 3, @@ -103,7 +103,7 @@ fn builtin_templates() -> Vec { label: "Epic Breakdown".into(), icon: "ungroup".into(), prompt: "Epic {{task_id}} needs to be broken down into concrete child tasks. \ - Start by using the `get_task` MCP tool to fetch the epic details. \ + Start by calling the `get_instructions` MCP tool to get your session instructions and epic details. \ Analyze the epic's scope and body, then decompose it into smaller, \ actionable child tasks using the `create_task` MCP tool — \ make sure to set `epic_id` to \"{{task_id}}\" for each child task. \ @@ -169,17 +169,42 @@ fn load_templates(conn: &Connection) -> Result, AppError> { }) .unwrap_or_else(|_| builtin_templates()); - // Ensure all session templates exist (in case new ones were added in an update) let mut result = templates; let builtins = builtin_templates(); + let mut dirty = false; + + // Remove legacy "continuous" templates (renamed to "queue") + let before_len = result.len(); + result.retain(|t| t.session_mode.as_deref() != Some("continuous")); + if result.len() != before_len { + tracing::info!("Removed legacy 'continuous' prompt template(s)"); + dirty = true; + } + + // Ensure all builtin session templates exist and stay up-to-date for builtin in &builtins { - if builtin.category == PromptCategory::Session - && !result.iter().any(|t| t.id == builtin.id) - { + if builtin.category != PromptCategory::Session { + continue; + } + if let Some(existing) = result.iter_mut().find(|t| t.id == builtin.id) { + // Upgrade stale builtin prompts that are missing get_instructions + if !existing.prompt.contains("get_instructions") { + tracing::info!(id = %builtin.id, "Upgrading session prompt to include get_instructions"); + existing.prompt = builtin.prompt.clone(); + dirty = true; + } + } else { + // New session template added in an update — backfill result.push(builtin.clone()); + dirty = true; } } + // Persist fixes so we don't re-apply every load + if dirty { + let _ = save_templates(conn, &result); + } + Ok(result) } _ => { @@ -201,7 +226,7 @@ fn save_templates(conn: &Connection, templates: &[PromptTemplate]) -> Result<(), /// Validate that all required session templates are present. fn validate_templates(templates: &[PromptTemplate]) -> Result<(), AppError> { - let required_session_modes = ["task", "task-continue", "research", "continuous", "breakdown"]; + let required_session_modes = ["task", "task-continue", "research", "queue", "breakdown"]; for mode in &required_session_modes { let found = templates.iter().any(|t| { t.category == PromptCategory::Session @@ -216,7 +241,7 @@ fn validate_templates(templates: &[PromptTemplate]) -> Result<(), AppError> { Ok(()) } -// ── Public helpers (for use by session.rs, continuous.rs) ── +// ── Public helpers (for use by session.rs, queue.rs) ── /// Get the prompt template for a specific session mode. /// Falls back to the built-in default if not found in the DB. @@ -276,7 +301,7 @@ mod tests { #[test] fn builtin_templates_has_all_session_modes() { let templates = builtin_templates(); - let modes = ["task", "task-continue", "research", "continuous", "breakdown"]; + let modes = ["task", "task-continue", "research", "queue", "breakdown"]; for mode in &modes { assert!( templates.iter().any(|t| t.session_mode.as_deref() == Some(mode)), diff --git a/src-tauri/src/commands/queue.rs b/src-tauri/src/commands/queue.rs new file mode 100644 index 0000000..a078948 --- /dev/null +++ b/src-tauri/src/commands/queue.rs @@ -0,0 +1,814 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use tauri::{AppHandle, Emitter, Manager, State}; +use tokio::sync::Mutex as TokioMutex; + +use crate::acp::state::AcpState; +use crate::commands::prompts; +use crate::queue::{ + self, BranchingStrategy, QueueModeUpdate, QueueItem, QueueRun, + QueueState, QueueStatus, QueueItemStatus, +}; +use std::collections::HashSet; +use crate::db; +use crate::db::models::SessionTransport; +use crate::db::DbState; +use crate::error::AppError; +use crate::mcp::McpState; +use crate::pty::PtyState; +use crate::session; + +/// Validate the dependency graph for a set of tasks and return a topologically +/// sorted order with strategy suggestions. Called by the frontend on each +/// selection change in the launch dialog. +#[tauri::command] +pub fn validate_queue_deps( + db: State<'_, DbState>, + project_id: String, + task_ids: Vec, +) -> Result { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + queue::validate_dependency_graph(&conn, &project_id, &task_ids) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub fn start_queue_mode( + db: State<'_, DbState>, + pty: State<'_, PtyState>, + mcp: State<'_, Arc>>, + acp: State<'_, AcpState>, + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, + task_ids: Vec, + strategy: String, + base_branch: Option, + agent_name: Option, + model: Option, + transport: Option, + worktree_strategy: Option, +) -> Result { + let transport = match transport.as_deref() { + Some("acp") => SessionTransport::Acp, + _ => SessionTransport::Pty, + }; + if task_ids.len() < 2 { + return Err(AppError::Validation( + "Queue mode requires at least 2 tasks".into(), + )); + } + + let strategy = match strategy.as_str() { + "chained" => BranchingStrategy::Chained, + "dag" => BranchingStrategy::Dag, + _ => BranchingStrategy::Independent, + }; + + #[allow(deprecated)] + let wt_strategy = match worktree_strategy.as_deref() { + Some("integration") => Some(queue::WorktreeStrategy::Integration), + Some("sequential") => Some(queue::WorktreeStrategy::Sequential), + Some("independent") => Some(queue::WorktreeStrategy::Independent), + _ => None, // Will be derived from branching strategy later + }; + + // Check if there's already an active run for this project + { + let guard = cont.blocking_lock(); + if guard.contains_key(&project_id) { + return Err(AppError::Validation( + "Queue mode is already active for this project. Dismiss or stop it first.".into(), + )); + } + } + + // Get MCP port BEFORE acquiring DB lock to avoid nested mutex contention + let mcp_port = session::get_mcp_port(&mcp); + + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + + // Validate dependency graph and get topologically sorted order + let validated = queue::validate_dependency_graph(&conn, &project_id, &task_ids)?; + let task_ids = validated.sorted_ids; // reorder to dependency-safe execution order + + // Validate all tasks exist and are in "ready" status, resolve per-task agent. + // For DAG strategy, also collect in-queue dependencies per task. + let queued_set: HashSet<&str> = task_ids.iter().map(|s| s.as_str()).collect(); + let mut queue: Vec = Vec::with_capacity(task_ids.len()); + for tid in &task_ids { + let task = db::tasks::get(&conn, tid, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Task {tid}")))?; + if task.status.as_str() != "ready" { + return Err(AppError::Validation(format!( + "Task {} is not in 'ready' status (current: {})", + tid, task.status + ))); + } + // Per-task agent: task.agent overrides run-level agent_name + let resolved_agent = task.agent.or_else(|| agent_name.clone()); + // For DAG: keep only in-queue deps (external deps already validated as done) + let in_queue_deps = if strategy == BranchingStrategy::Dag { + task.depends_on.iter() + .filter(|d| queued_set.contains(d.as_str())) + .cloned() + .collect() + } else { + vec![] + }; + queue.push(QueueItem { + task_id: tid.clone(), + status: QueueItemStatus::Pending, + session_id: None, + error: None, + agent_name: resolved_agent, + depends_on: in_queue_deps, + }); + } + + let mut last_branch: Option = None; + + // Generate run ID for branch naming + let run_id = db::generate_id("qr"); + + // Determine effective worktree strategy + let effective_wt_strategy = wt_strategy.unwrap_or(match strategy { + BranchingStrategy::Chained | BranchingStrategy::Dag => queue::WorktreeStrategy::Integration, + BranchingStrategy::Independent => queue::WorktreeStrategy::Independent, + }); + + // Create integration branch if using Integration strategy + let mut integration_branch_id: Option = None; + if effective_wt_strategy == queue::WorktreeStrategy::Integration { + if let Some(base) = base_branch.as_deref().or(Some("main")) { + let project = db::projects::get(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Project {project_id}")))?; + let repo_path = std::path::Path::new(&project.path); + let ib_name = format!("queue/{}", run_id); + + match crate::git::create_integration_branch(repo_path, &ib_name, base) { + Ok(_) => { + let pending: Vec = task_ids.clone(); + match db::integration_branches::create( + &conn, + &db::integration_branches::CreateParams { + run_type: "queue", + run_id: &run_id, + project_id: &project_id, + branch_name: &ib_name, + base_branch: base, + worktree_strategy: effective_wt_strategy.as_str(), + pending_tasks: &pending, + }, + ) { + Ok(ib) => { + integration_branch_id = Some(ib.id); + tracing::info!(branch = %ib_name, "Created integration branch for queue run"); + } + Err(e) => { + tracing::error!(%e, "Failed to create integration branch record"); + } + } + } + Err(e) => { + tracing::error!(%e, "Failed to create integration branch — falling back to independent"); + // Don't fail the whole run — fall back + } + } + } + } + + // For integration strategy, the first task branches from the integration branch + let effective_base_for_first_task = if effective_wt_strategy == queue::WorktreeStrategy::Integration && integration_branch_id.is_some() { + Some(format!("queue/{}", run_id)) + } else { + base_branch.clone() + }; + + // Load queue mode template once for all tasks + let cont_template = prompts::get_session_prompt(&conn, "queue"); + + match strategy { + BranchingStrategy::Independent => { + // Launch ALL tasks in parallel — each gets its own session from base branch + for (i, tid) in task_ids.iter().enumerate() { + queue[i].status = QueueItemStatus::Running; + let task_agent = queue[i].agent_name.as_deref(); + + let mut vars = HashMap::new(); + vars.insert("task_id", tid.as_str()); + vars.insert("mode", "parallel"); + let user_prompt = Some(session::interpolate_vars(&cont_template.prompt, &vars)); + + // Each MCP port lookup needs to be fresh for each session + let port = session::get_mcp_port(&mcp); + let result = match transport { + SessionTransport::Acp => { + let opts = session::AcpTaskSessionOpts { + task_id: tid, + agent_name: task_agent, + model: model.as_deref(), + create_worktree: true, + base_branch: base_branch.as_deref(), + user_prompt: user_prompt.as_deref(), + is_trust_mode: true, + }; + session::start_acp_task_session(&conn, &app, &mcp, &acp, port, &project_id, &opts) + } + SessionTransport::Pty => { + session::start_task_session( + &conn, &pty, &app, &mcp, port, &project_id, + tid, task_agent, model.as_deref(), + true, base_branch.as_deref(), user_prompt.as_deref(), + ) + } + }; + match result { + Ok(session) => { + queue[i].session_id = Some(session.id.clone()); + } + Err(e) => { + tracing::error!(task_id = %tid, %e, "Failed to launch parallel session"); + queue[i].status = QueueItemStatus::Error; + queue[i].error = Some(format!("Launch failed: {e}")); + } + } + } + } + BranchingStrategy::Chained => { + // Sequential — launch only the first task + queue[0].status = QueueItemStatus::Running; + let first_agent = queue[0].agent_name.as_deref(); + + let first_task_id = &task_ids[0]; + let mut vars = HashMap::new(); + vars.insert("task_id", first_task_id.as_str()); + vars.insert("mode", "chained"); + let first_user_prompt = Some(session::interpolate_vars(&cont_template.prompt, &vars)); + + let launch_base = effective_base_for_first_task.as_deref().or(base_branch.as_deref()); + + let first_session = match transport { + SessionTransport::Acp => { + let opts = session::AcpTaskSessionOpts { + task_id: first_task_id, + agent_name: first_agent, + model: model.as_deref(), + create_worktree: true, + base_branch: launch_base, + user_prompt: first_user_prompt.as_deref(), + is_trust_mode: true, + }; + session::start_acp_task_session(&conn, &app, &mcp, &acp, mcp_port, &project_id, &opts)? + } + SessionTransport::Pty => { + session::start_task_session( + &conn, &pty, &app, &mcp, mcp_port, &project_id, + first_task_id, first_agent, model.as_deref(), + true, launch_base, first_user_prompt.as_deref(), + )? + } + }; + + queue[0].session_id = Some(first_session.id.clone()); + + // Record the branch for chaining + last_branch = first_session + .worktree_path + .as_ref() + .and_then(|_| { + db::tasks::get(&conn, first_task_id, &project_id) + .ok() + .flatten() + .and_then(|t| t.branch) + }); + } + BranchingStrategy::Dag => { + // Launch only root tasks (those with zero in-queue deps) in parallel. + // Downstream tasks launch automatically as deps complete via try_advance. + let root_indices: Vec = queue.iter().enumerate() + .filter(|(_, item)| item.depends_on.is_empty()) + .map(|(i, _)| i) + .collect(); + + tracing::info!( + root_count = root_indices.len(), + total = queue.len(), + "DAG: launching root tasks" + ); + + let launch_base = effective_base_for_first_task.as_deref().or(base_branch.as_deref()); + + for &i in &root_indices { + queue[i].status = QueueItemStatus::Running; + let task_agent = queue[i].agent_name.as_deref(); + let tid = &queue[i].task_id; + + let mut vars = HashMap::new(); + vars.insert("task_id", tid.as_str()); + vars.insert("mode", "dag"); + let user_prompt = Some(session::interpolate_vars(&cont_template.prompt, &vars)); + + let port = session::get_mcp_port(&mcp); + let result = match transport { + SessionTransport::Acp => { + let opts = session::AcpTaskSessionOpts { + task_id: tid, + agent_name: task_agent, + model: model.as_deref(), + create_worktree: true, + base_branch: launch_base, + user_prompt: user_prompt.as_deref(), + is_trust_mode: true, + }; + session::start_acp_task_session(&conn, &app, &mcp, &acp, port, &project_id, &opts) + } + SessionTransport::Pty => { + session::start_task_session( + &conn, &pty, &app, &mcp, port, &project_id, + tid, task_agent, model.as_deref(), + true, launch_base, user_prompt.as_deref(), + ) + } + }; + match result { + Ok(session) => { + queue[i].session_id = Some(session.id.clone()); + } + Err(e) => { + tracing::error!(task_id = %tid, %e, "DAG: failed to launch root task"); + queue[i].status = QueueItemStatus::Error; + queue[i].error = Some(format!("Launch failed: {e}")); + } + } + } + + // Block dependents of any root tasks that failed to launch + let failed_roots: Vec = root_indices.iter() + .filter(|&&i| queue[i].status == QueueItemStatus::Error) + .map(|&i| queue[i].task_id.clone()) + .collect(); + for tid in &failed_roots { + queue::block_dependents_in_queue(&mut queue, tid); + } + } + } + + let run = QueueRun { + project_id: project_id.clone(), + status: QueueStatus::Running, + queue, + current_index: 0, + strategy, + base_branch, + agent_name, + model, + last_branch, + transport, + worktree_strategy: Some(effective_wt_strategy), + integration_branch_id: integration_branch_id.clone(), + run_id: Some(run_id), + }; + + // Store the run + { + let mut guard = cont.blocking_lock(); + guard.insert(project_id.clone(), run.clone()); + } + + tracing::info!( + project_id = %project_id, + strategy = ?run.strategy, + task_count = run.queue.len(), + agent = ?run.agent_name, + "Queue mode started" + ); + + let _ = app.emit( + "queue-mode-update", + QueueModeUpdate { + project_id, + run: run.clone(), + }, + ); + + Ok(run) +} + +#[tauri::command] +pub fn pause_queue_mode( + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result { + let mut guard = cont.blocking_lock(); + let run = guard + .get_mut(&project_id) + .ok_or_else(|| AppError::NotFound("No active queue run".into()))?; + + if run.status != QueueStatus::Running { + return Err(AppError::Validation( + "Queue mode is not running".into(), + )); + } + + run.status = QueueStatus::Paused; + tracing::info!(project_id = %project_id, "Queue mode paused"); + let result = run.clone(); + + let _ = app.emit( + "queue-mode-update", + QueueModeUpdate { + project_id, + run: result.clone(), + }, + ); + + Ok(result) +} + +#[tauri::command] +pub fn resume_queue_mode( + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result { + // First, set status to Running + { + let mut guard = cont.blocking_lock(); + let run = guard + .get_mut(&project_id) + .ok_or_else(|| AppError::NotFound("No active queue run".into()))?; + + if run.status != QueueStatus::Paused { + return Err(AppError::Validation( + "Queue mode is not paused".into(), + )); + } + + run.status = QueueStatus::Running; + tracing::info!(project_id = %project_id, "Queue mode resumed"); + + let _ = app.emit( + "queue-mode-update", + QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }, + ); + } + + // Check if any running task's session completed while paused (MCP reported done while paused). + // For chained mode: only check current_index. For dag/independent: check all running items. + let completed_indices: Vec = { + let guard = cont.blocking_lock(); + let mcp_state: tauri::State<'_, Arc>> = app.state(); + let mcp_guard = mcp_state.blocking_lock(); + if let Some(run) = guard.get(&project_id) { + run.queue.iter().enumerate() + .filter(|(_, item)| { + item.status == QueueItemStatus::Running + && item.session_id.as_ref().is_some_and(|sid| { + mcp_guard.sessions.get(sid.as_str()) + .map(|d| d.completed) + .unwrap_or(false) + }) + }) + .map(|(i, _)| i) + .collect() + } else { + vec![] + } + }; + + // Advance for each completed item + for idx in completed_indices { + { + let mut guard = cont.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + run.current_index = idx; + } + } + queue::try_advance(&app, &project_id)?; + } + + let guard = cont.blocking_lock(); + guard + .get(&project_id) + .cloned() + .ok_or_else(|| AppError::NotFound("Run completed during resume".into())) +} + +#[tauri::command] +pub fn stop_queue_mode( + db: State<'_, DbState>, + pty: State<'_, PtyState>, + mcp: State<'_, Arc>>, + acp: State<'_, AcpState>, + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result<(), AppError> { + let mut guard = cont.blocking_lock(); + let run = match guard.remove(&project_id) { + Some(r) => r, + None => return Ok(()), // no active run + }; + drop(guard); + tracing::info!(project_id = %project_id, "Queue mode stopped"); + + // Stop ALL currently running sessions (important for independent mode) + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + for item in &run.queue { + if item.status == QueueItemStatus::Running { + if let Some(sid) = &item.session_id { + let _ = session::stop_session(&conn, &pty, &app, &mcp, Some(&acp), sid); + } + } + } + + let completed_count = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + + // Emit finished event so the frontend properly clears the state + let _ = app.emit( + "queue-mode-finished", + queue::QueueModeFinished { + project_id, + completed_count, + }, + ); + + Ok(()) +} + +/// Dismiss a completed queue run — stops and removes all related sessions. +/// Called by the user from the queue mode bar after reviewing agent output. +#[tauri::command] +pub fn dismiss_queue_mode( + db: State<'_, DbState>, + pty: State<'_, PtyState>, + mcp: State<'_, Arc>>, + acp: State<'_, AcpState>, + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result<(), AppError> { + let mut guard = cont.blocking_lock(); + let run = match guard.remove(&project_id) { + Some(r) => r, + None => return Ok(()), // no active run + }; + drop(guard); + tracing::info!(project_id = %project_id, "Queue mode dismissed"); + + // Stop and remove ALL sessions that are still alive + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + for item in &run.queue { + if let Some(sid) = &item.session_id { + // stop_and_remove handles already-stopped sessions gracefully + let _ = session::stop_and_remove_session(&conn, &pty, &app, &mcp, Some(&acp), sid); + } + } + + let completed_count = run + .queue + .iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + + // Emit finished event to clear frontend state + let _ = app.emit( + "queue-mode-finished", + queue::QueueModeFinished { + project_id, + completed_count, + }, + ); + + Ok(()) +} + +#[tauri::command] +pub fn get_queue_mode_status( + cont: State<'_, QueueState>, + project_id: String, +) -> Result, AppError> { + let guard = cont.blocking_lock(); + Ok(guard.get(&project_id).cloned()) +} + +/// Get the integration branch state for the active queue run. +#[tauri::command] +pub fn get_integration_branch( + db: State<'_, DbState>, + project_id: String, +) -> Result, AppError> { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + db::integration_branches::get_active_for_project(&conn, &project_id) +} + +/// Retry a merge after the user has resolved conflicts manually. +/// +/// The user resolves conflicts in the task's worktree, commits the resolution, +/// then clicks "Retry merge" in the UI. This re-attempts the merge and resumes the run. +#[tauri::command] +pub fn resolve_merge_conflict( + db: State<'_, DbState>, + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result<(), AppError> { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + + let ib = db::integration_branches::get_active_for_project(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound("No active integration branch".into()))?; + + let task_id = ib.conflict_task.as_ref() + .ok_or_else(|| AppError::Validation("No conflict task to resolve".into()))? + .clone(); + + let task = db::tasks::get(&conn, &task_id, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Task {task_id}")))?; + + let task_branch = task.branch.as_ref() + .ok_or_else(|| AppError::Validation("Task has no branch".into()))?; + + let project = db::projects::get(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Project {project_id}")))?; + + let repo_path = std::path::Path::new(&project.path); + let commit_msg = format!("Merge {}: {}", task_id, task.title); + + // Retry the merge + match crate::git::merge_task_branch(repo_path, &ib.branch_name, task_branch, &commit_msg)? { + crate::git::MergeResult::Success { commit_hash } => { + // Clear conflict + db::integration_branches::clear_conflict(&conn, &ib.id)?; + db::integration_branches::record_merge(&conn, &ib.id, &task_id)?; + + // Advance task to done + let _ = crate::commands::tasks::do_update_task_status( + &conn, &project_id, &task_id, "done", + ); + + // Clean up worktree + if let Some(wt_path) = &task.worktree_path { + let wt = std::path::Path::new(wt_path); + if let Err(e) = crate::git::delete_worktree(repo_path, wt) { + tracing::warn!(%e, task_id = %task_id, "Failed to cleanup worktree after conflict resolution"); + } else { + let _ = db::tasks::update_worktree(&conn, &task_id, &project_id, None); + } + } + + // Delete task branch + let _ = crate::git::delete_task_branch(repo_path, task_branch); + + let _ = app.emit("task-merged", queue::TaskMergedEvent { + task_id: task_id.clone(), + integration_branch: ib.branch_name.clone(), + merge_commit: commit_hash, + }); + + // Resume the run + drop(conn); + { + let mut guard = cont.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + run.status = QueueStatus::Running; + let _ = app.emit("queue-mode-update", queue::QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + } + + // Try to advance to next task + queue::try_advance(&app, &project_id)?; + + tracing::info!(task_id = %task_id, "Merge conflict resolved, run resumed"); + Ok(()) + } + crate::git::MergeResult::Conflict { files } => { + // Still has conflicts + db::integration_branches::record_conflict(&conn, &ib.id, &task_id, &files)?; + + Err(AppError::Git(format!( + "Merge still has conflicts in {} files. Resolve them and try again.", + files.len() + ))) + } + } +} + +/// Skip a conflicted task and continue the run. +#[tauri::command] +pub fn skip_conflicted_task( + db: State<'_, DbState>, + cont: State<'_, QueueState>, + app: AppHandle, + project_id: String, +) -> Result<(), AppError> { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + + let ib = db::integration_branches::get_active_for_project(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound("No active integration branch".into()))?; + + let task_id = ib.conflict_task.as_ref() + .ok_or_else(|| AppError::Validation("No conflict task to skip".into()))? + .clone(); + + // Clear conflict and remove from pending + db::integration_branches::clear_conflict(&conn, &ib.id)?; + db::integration_branches::remove_pending_task(&conn, &ib.id, &task_id)?; + + // Mark the queue item as error + drop(conn); + { + let mut guard = cont.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + for item in &mut run.queue { + if item.task_id == task_id { + item.status = QueueItemStatus::Error; + item.error = Some("Skipped due to merge conflict".to_string()); + break; + } + } + run.status = QueueStatus::Running; + let _ = app.emit("queue-mode-update", queue::QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + } + + // Advance to next task + queue::try_advance(&app, &project_id)?; + + tracing::info!(task_id = %task_id, "Skipped conflicted task, run resumed"); + Ok(()) +} + +/// Clean up an integration branch and its associated branches/worktrees. +#[tauri::command] +pub fn cleanup_integration_branch( + db: State<'_, DbState>, + project_id: String, + integration_branch_id: String, + delete_remote: bool, +) -> Result<(), AppError> { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + + let ib = db::integration_branches::get(&conn, &integration_branch_id)? + .ok_or_else(|| AppError::NotFound("Integration branch".into()))?; + + let project = db::projects::get(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Project {project_id}")))?; + + let repo_path = std::path::Path::new(&project.path); + + // Delete integration branch locally + if let Err(e) = crate::git::delete_branch(repo_path, &ib.branch_name) { + tracing::warn!(%e, branch = %ib.branch_name, "Failed to delete local integration branch"); + } + + // Delete remote branch if requested and it was pushed + if delete_remote && ib.pushed { + if let Err(e) = crate::git::delete_remote_branch(repo_path, &ib.branch_name, "origin") { + tracing::warn!(%e, branch = %ib.branch_name, "Failed to delete remote integration branch"); + } + } + + // Mark as cleaned up + db::integration_branches::mark_cleaned_up(&conn, &integration_branch_id)?; + + tracing::info!(branch = %ib.branch_name, "Integration branch cleaned up"); + Ok(()) +} + +/// Push the integration branch to remote manually. +#[tauri::command] +pub fn push_integration_branch( + db: State<'_, DbState>, + project_id: String, + integration_branch_id: String, +) -> Result<(), AppError> { + let conn = db.lock().map_err(|e| AppError::Database(e.to_string()))?; + + let ib = db::integration_branches::get(&conn, &integration_branch_id)? + .ok_or_else(|| AppError::NotFound("Integration branch".into()))?; + + let project = db::projects::get(&conn, &project_id)? + .ok_or_else(|| AppError::NotFound(format!("Project {project_id}")))?; + + let repo_path = std::path::Path::new(&project.path); + + crate::git::push_integration_branch(repo_path, &ib.branch_name, "origin")?; + db::integration_branches::mark_pushed(&conn, &integration_branch_id)?; + + tracing::info!(branch = %ib.branch_name, "Pushed integration branch to remote"); + Ok(()) +} diff --git a/src-tauri/src/continuous.rs b/src-tauri/src/continuous.rs deleted file mode 100644 index 9b093bc..0000000 --- a/src-tauri/src/continuous.rs +++ /dev/null @@ -1,501 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, Manager}; -use tokio::sync::Mutex as TokioMutex; - -use crate::acp::state::AcpState; -use crate::commands::prompts; -use crate::db; -use crate::db::models::SessionTransport; -use crate::db::DbState; -use crate::error::AppError; -use crate::mcp::McpState; -use crate::pty::PtyState; -use crate::session; - -// ── Types ── - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum ContinuousStatus { - Running, - Paused, - Completed, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum QueueItemStatus { - Pending, - Running, - Completed, - Error, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum BranchingStrategy { - Independent, - Chained, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContinuousQueueItem { - pub task_id: String, - pub status: QueueItemStatus, - pub session_id: Option, - pub error: Option, - /// Resolved agent for this task (task-level override or run-level default). - pub agent_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContinuousRun { - pub project_id: String, - pub status: ContinuousStatus, - pub queue: Vec, - pub current_index: usize, - pub strategy: BranchingStrategy, - pub base_branch: Option, - pub agent_name: Option, - pub model: Option, - pub last_branch: Option, - /// Transport for session launch (pty or acp). Defaults to pty. - #[serde(default)] - pub transport: SessionTransport, -} - -pub type ContinuousState = Arc>>; - -// ── Event payloads ── - -#[derive(Clone, Serialize)] -pub struct ContinuousModeUpdate { - pub project_id: String, - pub run: ContinuousRun, -} - -#[derive(Clone, Serialize)] -pub struct ContinuousModeFinished { - pub project_id: String, - pub completed_count: usize, -} - -// ── State constructor ── - -pub fn new_state() -> ContinuousState { - Arc::new(TokioMutex::new(HashMap::new())) -} - -// ── Lookups ── - -/// Find the project_id for a continuous run that contains the given session_id. -pub async fn find_run_by_session( - state: &ContinuousState, - session_id: &str, -) -> Option { - let guard = state.lock().await; - for (project_id, run) in guard.iter() { - for item in &run.queue { - if item.session_id.as_deref() == Some(session_id) { - return Some(project_id.clone()); - } - } - } - None -} - -// ── Core advance logic ── - -/// Advance the continuous run after a session completes. -/// For chained strategy: launch the next task in sequence. -/// For independent strategy: mark as complete, check if all are done. -pub fn try_advance(app: &AppHandle, project_id: &str) -> Result<(), AppError> { - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - let mut guard = cont_state.blocking_lock(); - - let run = match guard.get_mut(project_id) { - Some(r) => r, - None => return Ok(()), // no active run - }; - - // Don't advance if paused - if run.status == ContinuousStatus::Paused { - return Ok(()); - } - - match run.strategy { - BranchingStrategy::Independent => { - // For independent mode, mark the current item as completed. - // The session_id matching happens in mark_complete_and_advance. - // Here we just mark current_index item (set by the caller). - if run.current_index < run.queue.len() { - run.queue[run.current_index].status = QueueItemStatus::Completed; - } - - // Check if all items are finished (completed or error) - let all_done = run.queue.iter().all(|i| { - matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) - }); - - if all_done { - let completed_count = run.queue.iter() - .filter(|i| i.status == QueueItemStatus::Completed) - .count(); - run.status = ContinuousStatus::Completed; - tracing::info!(project_id, completed_count, "Continuous mode completed all tasks"); - - // Keep run in state — user must dismiss to close sessions - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.to_string(), - run: run.clone(), - }); - } else { - // Still have running items — just emit an update - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.to_string(), - run: run.clone(), - }); - } - - Ok(()) - } - BranchingStrategy::Chained => { - // Mark current item as completed - if run.current_index < run.queue.len() { - run.queue[run.current_index].status = QueueItemStatus::Completed; - } - - // Move to next - let next_index = run.current_index + 1; - if next_index >= run.queue.len() { - // All done - let completed_count = run.queue.iter() - .filter(|i| i.status == QueueItemStatus::Completed) - .count(); - run.status = ContinuousStatus::Completed; - tracing::info!(project_id, completed_count, "Continuous mode completed all tasks"); - - // Keep run in state — user must dismiss to close sessions - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.to_string(), - run: run.clone(), - }); - return Ok(()); - } - - run.current_index = next_index; - run.queue[next_index].status = QueueItemStatus::Running; - - let task_id = run.queue[next_index].task_id.clone(); - tracing::info!(project_id, next_task_id = %task_id, "Continuous mode advancing to next task"); - // Use per-task agent (already resolved: task.agent || run.agent_name) - let agent = run.queue[next_index].agent_name.clone(); - let model = run.model.clone(); - let base_branch = run.base_branch.clone(); - let last_branch = run.last_branch.clone(); - let transport = run.transport; - let pid = project_id.to_string(); - - // Emit update before launching (shows "running" on next item) - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: pid.clone(), - run: run.clone(), - }); - - // Drop lock before launching session (it acquires DB lock) - drop(guard); - - // Chained: branch from the previous task's branch - let launch_base = last_branch.as_deref().or(base_branch.as_deref()); - - let session = launch_task_for_continuous(app, &pid, &task_id, agent.as_deref(), model.as_deref(), launch_base, transport)?; - - // Look up the task's branch name for chained strategy - let task_branch = if session.worktree_path.is_some() { - let db_state: tauri::State<'_, DbState> = app.state(); - db_state.lock().ok() - .and_then(|conn| db::tasks::get(&conn, &task_id, &pid).ok().flatten()) - .and_then(|t| t.branch) - } else { - None - }; - - // Update the queue item with the session ID + record branch for chaining - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - let mut guard = cont_state.blocking_lock(); - if let Some(run) = guard.get_mut(&pid) { - run.queue[next_index].session_id = Some(session.id.clone()); - if let Some(branch) = task_branch { - run.last_branch = Some(branch); - } - - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: pid.clone(), - run: run.clone(), - }); - } - - Ok(()) - } - } -} - -/// Mark the completed session's queue item and advance the queue. -/// Called from the MCP report_complete handler after a delay. -/// Sessions are NOT stopped here — they stay alive so the user can review -/// agent summaries. The user dismisses them via the continuous mode bar. -pub fn mark_complete_and_advance(app: &AppHandle, session_id: &str) { - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - let (project_id, item_index) = { - let guard = cont_state.blocking_lock(); - let mut found = None; - for (pid, run) in guard.iter() { - for (i, item) in run.queue.iter().enumerate() { - if item.session_id.as_deref() == Some(session_id) { - found = Some((pid.clone(), i)); - break; - } - } - if found.is_some() { break; } - } - match found { - Some(f) => f, - None => return, - } - }; - - // Set current_index to the completed item so try_advance marks the right one - { - let mut guard = cont_state.blocking_lock(); - if let Some(run) = guard.get_mut(&project_id) { - run.current_index = item_index; - } - } - - // Advance to next task (or check if all done for independent mode) - if let Err(e) = try_advance(app, &project_id) { - tracing::error!(%e, session_id, "Failed to advance continuous mode after session"); - // Pause on error - let mut guard = cont_state.blocking_lock(); - if let Some(run) = guard.get_mut(&project_id) { - run.status = ContinuousStatus::Paused; - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }); - } - } -} - -/// Handle PTY exit for a session that's part of a continuous run. -/// If the agent didn't call report_complete, this is a crash. -/// For chained mode: pause the run. -/// For independent mode: mark the item as error but continue others; finish if all done. -pub fn handle_pty_exit(app: &AppHandle, session_id: &str) { - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - let mcp_state: tauri::State<'_, Arc>> = app.state(); - - // Check if this session is part of a continuous run - let project_id = { - let guard = cont_state.blocking_lock(); - let mut found = None; - for (pid, run) in guard.iter() { - if run.status != ContinuousStatus::Running { continue; } - for item in &run.queue { - if item.session_id.as_deref() == Some(session_id) - && item.status == QueueItemStatus::Running - { - found = Some(pid.clone()); - break; - } - } - if found.is_some() { break; } - } - found - }; - - let Some(project_id) = project_id else { return }; - - // Check if MCP got a completion for this session - let completed = { - let guard = mcp_state.blocking_lock(); - guard.sessions.get(session_id) - .map(|d| d.completed) - .unwrap_or(false) - }; - - if completed { - // Normal exit after report_complete — auto-advance is already scheduled - return; - } - - // Agent crashed without reporting complete — mark error - tracing::warn!(session_id, "PTY exited without report_complete in continuous run"); - - let mut guard = cont_state.blocking_lock(); - if let Some(run) = guard.get_mut(&project_id) { - // Mark the specific queue item as error - for item in &mut run.queue { - if item.session_id.as_deref() == Some(session_id) { - item.status = QueueItemStatus::Error; - item.error = Some("Agent exited without completing".to_string()); - break; - } - } - - match run.strategy { - BranchingStrategy::Independent => { - // Check if all items are finished (completed or error) - let all_done = run.queue.iter().all(|i| { - matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) - }); - - if all_done { - run.status = ContinuousStatus::Completed; - } - - // Emit update (keep run in state for user to dismiss) - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }); - } - BranchingStrategy::Chained => { - // Chained mode: pause on any error - run.status = ContinuousStatus::Paused; - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }); - } - } - } -} - -/// Handle manual session stop. -/// For chained mode: pause the entire run. -/// For independent mode: mark item as error, check if all done, continue otherwise. -pub fn handle_manual_stop(app: &AppHandle, session_id: &str) { - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - - let project_id = { - let guard = cont_state.blocking_lock(); - let mut found = None; - for (pid, run) in guard.iter() { - if run.status != ContinuousStatus::Running { continue; } - for item in &run.queue { - if item.session_id.as_deref() == Some(session_id) { - found = Some(pid.clone()); - break; - } - } - if found.is_some() { break; } - } - found - }; - - let Some(project_id) = project_id else { return }; - - tracing::info!(session_id, "Continuous mode: manual session stop"); - - let mut guard = cont_state.blocking_lock(); - if let Some(run) = guard.get_mut(&project_id) { - // Mark the stopped item - for item in &mut run.queue { - if item.session_id.as_deref() == Some(session_id) { - item.status = QueueItemStatus::Error; - item.error = Some("Manually stopped".to_string()); - break; - } - } - - match run.strategy { - BranchingStrategy::Independent => { - // Check if all items are finished - let all_done = run.queue.iter().all(|i| { - matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) - }); - - if all_done { - run.status = ContinuousStatus::Completed; - } - - // Emit update (keep run in state for user to dismiss) - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }); - } - BranchingStrategy::Chained => { - // Chained mode: pause entire run - run.status = ContinuousStatus::Paused; - let _ = app.emit("continuous-mode-update", ContinuousModeUpdate { - project_id: project_id.clone(), - run: run.clone(), - }); - } - } - } -} - -// ── Internal helpers ── - -/// Launch a task session for continuous mode. -fn launch_task_for_continuous( - app: &AppHandle, - project_id: &str, - task_id: &str, - agent: Option<&str>, - model: Option<&str>, - base_branch: Option<&str>, - transport: SessionTransport, -) -> Result { - let db_state: tauri::State<'_, DbState> = app.state(); - let mcp_state: tauri::State<'_, Arc>> = app.state(); - - // Get MCP port BEFORE acquiring DB lock to avoid nested mutex contention - let mcp_port = session::get_mcp_port(&mcp_state); - let conn = db_state.lock().map_err(|e| AppError::Database(e.to_string()))?; - - let template = prompts::get_session_prompt(&conn, "continuous"); - let mut vars = HashMap::new(); - vars.insert("task_id", task_id); - vars.insert("mode", "chained"); - let user_prompt = Some(session::interpolate_vars(&template.prompt, &vars)); - - match transport { - SessionTransport::Acp => { - let acp_state: tauri::State<'_, AcpState> = app.state(); - let opts = session::AcpTaskSessionOpts { - task_id, - agent_name: agent, - model, - create_worktree: true, - base_branch, - user_prompt: user_prompt.as_deref(), - is_trust_mode: true, - }; - session::start_acp_task_session(&conn, app, &mcp_state, &acp_state, mcp_port, project_id, &opts) - } - SessionTransport::Pty => { - let pty_state: tauri::State<'_, PtyState> = app.state(); - session::start_task_session( - &conn, - &pty_state, - app, - &mcp_state, - mcp_port, - project_id, - task_id, - agent, - model, - true, // always create worktree - base_branch, - user_prompt.as_deref(), - ) - } - } -} diff --git a/src-tauri/src/db/integration_branches.rs b/src-tauri/src/db/integration_branches.rs new file mode 100644 index 0000000..7523543 --- /dev/null +++ b/src-tauri/src/db/integration_branches.rs @@ -0,0 +1,223 @@ +use rusqlite::Connection; + +use crate::db; +use crate::error::AppError; + +use super::models::IntegrationBranch; + +/// Parameters for creating an integration branch. +pub struct CreateParams<'a> { + pub run_type: &'a str, + pub run_id: &'a str, + pub project_id: &'a str, + pub branch_name: &'a str, + pub base_branch: &'a str, + pub worktree_strategy: &'a str, + pub pending_tasks: &'a [String], +} + +/// Create a new integration branch record. +pub fn create( + conn: &Connection, + params: &CreateParams<'_>, +) -> Result { + let id = db::generate_id("ib"); + let pending_json = serde_json::to_string(params.pending_tasks).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "INSERT INTO integration_branches (id, run_type, run_id, project_id, branch_name, base_branch, worktree_strategy, pending_tasks) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![id, params.run_type, params.run_id, params.project_id, params.branch_name, params.base_branch, params.worktree_strategy, pending_json], + )?; + + get(conn, &id)?.ok_or_else(|| AppError::NotFound("Integration branch just created".into())) +} + +/// Get an integration branch by ID. +pub fn get(conn: &Connection, id: &str) -> Result, AppError> { + let mut stmt = conn.prepare( + "SELECT id, run_type, run_id, project_id, branch_name, base_branch, worktree_strategy, + merged_tasks, pending_tasks, conflict_task, conflict_files, pushed, pr_url, status, + created_at, updated_at + FROM integration_branches WHERE id = ?1", + )?; + + let result = stmt.query_row([id], row_to_branch).optional()?; + Ok(result) +} + +/// Get the active integration branch for a run. +#[allow(dead_code)] +pub fn get_by_run( + conn: &Connection, + run_type: &str, + run_id: &str, +) -> Result, AppError> { + let mut stmt = conn.prepare( + "SELECT id, run_type, run_id, project_id, branch_name, base_branch, worktree_strategy, + merged_tasks, pending_tasks, conflict_task, conflict_files, pushed, pr_url, status, + created_at, updated_at + FROM integration_branches WHERE run_type = ?1 AND run_id = ?2 AND status != 'cleaned_up' + ORDER BY created_at DESC LIMIT 1", + )?; + + let result = stmt.query_row([run_type, run_id], row_to_branch).optional()?; + Ok(result) +} + +/// Get the active integration branch for a project. +pub fn get_active_for_project( + conn: &Connection, + project_id: &str, +) -> Result, AppError> { + let mut stmt = conn.prepare( + "SELECT id, run_type, run_id, project_id, branch_name, base_branch, worktree_strategy, + merged_tasks, pending_tasks, conflict_task, conflict_files, pushed, pr_url, status, + created_at, updated_at + FROM integration_branches WHERE project_id = ?1 AND status IN ('active', 'conflict') + ORDER BY created_at DESC LIMIT 1", + )?; + + let result = stmt.query_row([project_id], row_to_branch).optional()?; + Ok(result) +} + +/// Record a successful task merge. +pub fn record_merge( + conn: &Connection, + id: &str, + task_id: &str, +) -> Result<(), AppError> { + let ib = get(conn, id)?.ok_or_else(|| AppError::NotFound("Integration branch".into()))?; + + let mut merged: Vec = ib.merged_tasks; + if !merged.contains(&task_id.to_string()) { + merged.push(task_id.to_string()); + } + + let mut pending: Vec = ib.pending_tasks; + pending.retain(|t| t != task_id); + + let merged_json = serde_json::to_string(&merged).unwrap_or_else(|_| "[]".to_string()); + let pending_json = serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "UPDATE integration_branches SET merged_tasks = ?1, pending_tasks = ?2, updated_at = datetime('now') WHERE id = ?3", + rusqlite::params![merged_json, pending_json, id], + )?; + + Ok(()) +} + +/// Record a merge conflict. +pub fn record_conflict( + conn: &Connection, + id: &str, + task_id: &str, + conflict_files: &[String], +) -> Result<(), AppError> { + let files_json = serde_json::to_string(conflict_files).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "UPDATE integration_branches SET conflict_task = ?1, conflict_files = ?2, status = 'conflict', updated_at = datetime('now') WHERE id = ?3", + rusqlite::params![task_id, files_json, id], + )?; + + Ok(()) +} + +/// Clear a conflict (after user resolution or skip). +pub fn clear_conflict(conn: &Connection, id: &str) -> Result<(), AppError> { + conn.execute( + "UPDATE integration_branches SET conflict_task = NULL, conflict_files = '[]', status = 'active', updated_at = datetime('now') WHERE id = ?1", + [id], + )?; + Ok(()) +} + +/// Mark the integration branch as completed (all tasks merged). +pub fn mark_completed(conn: &Connection, id: &str) -> Result<(), AppError> { + conn.execute( + "UPDATE integration_branches SET status = 'completed', updated_at = datetime('now') WHERE id = ?1", + [id], + )?; + Ok(()) +} + +/// Mark the integration branch as pushed to remote. +pub fn mark_pushed(conn: &Connection, id: &str) -> Result<(), AppError> { + conn.execute( + "UPDATE integration_branches SET pushed = 1, updated_at = datetime('now') WHERE id = ?1", + [id], + )?; + Ok(()) +} + +/// Store the PR URL. +#[allow(dead_code)] +pub fn set_pr_url(conn: &Connection, id: &str, pr_url: &str) -> Result<(), AppError> { + conn.execute( + "UPDATE integration_branches SET pr_url = ?1, updated_at = datetime('now') WHERE id = ?2", + rusqlite::params![pr_url, id], + )?; + Ok(()) +} + +/// Mark as cleaned up (final state after branches deleted). +pub fn mark_cleaned_up(conn: &Connection, id: &str) -> Result<(), AppError> { + conn.execute( + "UPDATE integration_branches SET status = 'cleaned_up', updated_at = datetime('now') WHERE id = ?1", + [id], + )?; + Ok(()) +} + +/// Remove a task from pending (e.g., on skip). +pub fn remove_pending_task( + conn: &Connection, + id: &str, + task_id: &str, +) -> Result<(), AppError> { + let ib = get(conn, id)?.ok_or_else(|| AppError::NotFound("Integration branch".into()))?; + + let mut pending: Vec = ib.pending_tasks; + pending.retain(|t| t != task_id); + + let pending_json = serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string()); + + conn.execute( + "UPDATE integration_branches SET pending_tasks = ?1, updated_at = datetime('now') WHERE id = ?2", + rusqlite::params![pending_json, id], + )?; + + Ok(()) +} + +// ── Row mapper ── + +fn row_to_branch(row: &rusqlite::Row) -> rusqlite::Result { + let merged_str: String = row.get(7)?; + let pending_str: String = row.get(8)?; + let conflict_files_str: String = row.get(10)?; + + Ok(IntegrationBranch { + id: row.get(0)?, + run_type: row.get(1)?, + run_id: row.get(2)?, + project_id: row.get(3)?, + branch_name: row.get(4)?, + base_branch: row.get(5)?, + worktree_strategy: row.get(6)?, + merged_tasks: serde_json::from_str(&merged_str).unwrap_or_default(), + pending_tasks: serde_json::from_str(&pending_str).unwrap_or_default(), + conflict_task: row.get(9)?, + conflict_files: serde_json::from_str(&conflict_files_str).unwrap_or_default(), + pushed: row.get(11)?, + pr_url: row.get(12)?, + status: row.get(13)?, + created_at: row.get(14)?, + updated_at: row.get(15)?, + }) +} + +use rusqlite::OptionalExtension; diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index ed32d59..d606333 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -362,7 +362,40 @@ CREATE INDEX idx_tasks_status ON tasks(project_id, status); CREATE INDEX idx_tasks_epic ON tasks(project_id, epic_id); "#; -const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002, MIGRATION_003, MIGRATION_004, MIGRATION_005, MIGRATION_006, MIGRATION_007, MIGRATION_008, MIGRATION_009, MIGRATION_010, MIGRATION_011, MIGRATION_012, MIGRATION_013, MIGRATION_014, MIGRATION_015, MIGRATION_016, MIGRATION_017, MIGRATION_018, MIGRATION_019]; +// Migration 020: Add orchestration fields to sessions for queue/autonomous mode tracking. +const MIGRATION_020: &str = r#" +ALTER TABLE sessions ADD COLUMN orchestration_source TEXT; +ALTER TABLE sessions ADD COLUMN orchestration_run_id TEXT; +"#; + +// Migration 021: Integration branches table for queue/autonomous mode merge tracking. +const MIGRATION_021: &str = r#" +CREATE TABLE integration_branches ( + id TEXT PRIMARY KEY, + run_type TEXT NOT NULL CHECK(run_type IN ('queue', 'autonomous')), + run_id TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + branch_name TEXT NOT NULL, + base_branch TEXT NOT NULL, + worktree_strategy TEXT NOT NULL DEFAULT 'integration' + CHECK(worktree_strategy IN ('integration', 'independent', 'sequential')), + merged_tasks TEXT NOT NULL DEFAULT '[]', + pending_tasks TEXT NOT NULL DEFAULT '[]', + conflict_task TEXT, + conflict_files TEXT NOT NULL DEFAULT '[]', + pushed INTEGER NOT NULL DEFAULT 0, + pr_url TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active', 'completed', 'conflict', 'cleaned_up')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX idx_integration_branches_project ON integration_branches(project_id); +CREATE INDEX idx_integration_branches_run ON integration_branches(run_type, run_id); +CREATE INDEX idx_integration_branches_status ON integration_branches(status); +"#; + +const MIGRATIONS: &[&str] = &[MIGRATION_001, MIGRATION_002, MIGRATION_003, MIGRATION_004, MIGRATION_005, MIGRATION_006, MIGRATION_007, MIGRATION_008, MIGRATION_009, MIGRATION_010, MIGRATION_011, MIGRATION_012, MIGRATION_013, MIGRATION_014, MIGRATION_015, MIGRATION_016, MIGRATION_017, MIGRATION_018, MIGRATION_019, MIGRATION_020, MIGRATION_021]; pub fn run(conn: &Connection) -> Result<(), rusqlite::Error> { conn.execute_batch( @@ -413,13 +446,13 @@ mod tests { let version: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(version, 19); + assert_eq!(version, 21); // Running again is a no-op run(&conn).unwrap(); let count: i64 = conn .query_row("SELECT COUNT(*) FROM schema_version", [], |r| r.get(0)) .unwrap(); - assert_eq!(count, 19); + assert_eq!(count, 21); } } diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index ad8948a..43ebdbb 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,4 +1,5 @@ pub mod agent_configs; +pub mod integration_branches; pub mod migrations; pub mod models; pub mod projects; diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 4a27e4d..6e5af52 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -306,6 +306,8 @@ pub struct Session { pub worktree_path: Option, pub mcp_connected: bool, pub acp_session_id: Option, + pub orchestration_source: Option, + pub orchestration_run_id: Option, pub started_at: String, pub ended_at: Option, } @@ -361,6 +363,28 @@ pub struct TaskActivity { pub data: serde_json::Value, } +// ── Integration Branch ── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntegrationBranch { + pub id: String, + pub run_type: String, + pub run_id: String, + pub project_id: String, + pub branch_name: String, + pub base_branch: String, + pub worktree_strategy: String, + pub merged_tasks: Vec, + pub pending_tasks: Vec, + pub conflict_task: Option, + pub conflict_files: Vec, + pub pushed: bool, + pub pr_url: Option, + pub status: String, + pub created_at: String, + pub updated_at: String, +} + // ── File Browser ── #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/db/sessions.rs b/src-tauri/src/db/sessions.rs index e9d0ec1..0ef79a0 100644 --- a/src-tauri/src/db/sessions.rs +++ b/src-tauri/src/db/sessions.rs @@ -31,7 +31,7 @@ pub fn create_with_id(conn: &Connection, id: &str, new: &NewSession) -> Result Result, rusqlite::Error> { let mut stmt = conn.prepare_cached( "SELECT id, project_id, task_id, name, mode, transport, agent, model, status, pid, worktree_path, - mcp_connected, acp_session_id, started_at, ended_at + mcp_connected, acp_session_id, orchestration_source, orchestration_run_id, started_at, ended_at FROM sessions WHERE id = ?1", )?; let mut rows = stmt.query_map(params![id], row_to_session)?; @@ -41,7 +41,7 @@ pub fn get(conn: &Connection, id: &str) -> Result, rusqlite::Err pub fn list_by_project(conn: &Connection, project_id: &str) -> Result, rusqlite::Error> { let mut stmt = conn.prepare_cached( "SELECT id, project_id, task_id, name, mode, transport, agent, model, status, pid, worktree_path, - mcp_connected, acp_session_id, started_at, ended_at + mcp_connected, acp_session_id, orchestration_source, orchestration_run_id, started_at, ended_at FROM sessions WHERE project_id = ?1 ORDER BY started_at DESC", )?; let rows = stmt.query_map(params![project_id], row_to_session)?; @@ -58,7 +58,7 @@ pub fn list_by_project(conn: &Connection, project_id: &str) -> Result Result, rusqlite::Error> { let mut stmt = conn.prepare_cached( "SELECT id, project_id, task_id, name, mode, transport, agent, model, status, pid, worktree_path, - mcp_connected, acp_session_id, started_at, ended_at + mcp_connected, acp_session_id, orchestration_source, orchestration_run_id, started_at, ended_at FROM sessions WHERE status IN ('starting', 'running', 'paused') ORDER BY started_at DESC", )?; @@ -199,8 +199,10 @@ fn row_to_session(row: &rusqlite::Row) -> Result { worktree_path: row.get(10)?, mcp_connected: mcp_int != 0, acp_session_id: row.get(12)?, - started_at: row.get(13)?, - ended_at: row.get(14)?, + orchestration_source: row.get(13)?, + orchestration_run_id: row.get(14)?, + started_at: row.get(15)?, + ended_at: row.get(16)?, }) } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index eadb84d..563b770 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -948,6 +948,164 @@ pub fn merge_branch(repo_path: &Path, branch_name: &str) -> Result }, +} + +/// Create an integration branch from a base ref. +/// +/// If the branch already exists, checks it out instead (idempotent). +/// Returns the branch name. +pub fn create_integration_branch( + repo_path: &Path, + branch_name: &str, + base_ref: &str, +) -> Result { + // Try to create a new branch from base_ref + let result = run_git(repo_path, &["branch", branch_name, base_ref]); + match result { + Ok(_) => { + tracing::info!(branch = branch_name, base = base_ref, "Created integration branch"); + } + Err(e) => { + let msg = e.to_string(); + if msg.contains("already exists") { + tracing::debug!(branch = branch_name, "Integration branch already exists"); + } else { + return Err(e); + } + } + } + Ok(branch_name.to_string()) +} + +/// Merge a task branch into the integration branch using `--no-ff`. +/// +/// This function: +/// 1. Checks out the integration branch in the main repo +/// 2. Attempts `git merge --no-ff ` +/// 3. On success: returns `MergeResult::Success` with the commit hash +/// 4. On conflict: aborts the merge and returns `MergeResult::Conflict` with affected files +/// +/// IMPORTANT: This operates on the main repo, not a worktree. The caller +/// should ensure no other operations are in-flight on the main repo. +pub fn merge_task_branch( + repo_path: &Path, + integration_branch: &str, + task_branch: &str, + commit_message: &str, +) -> Result { + // Save current branch to restore later + let original_branch = run_git(repo_path, &["rev-parse", "--abbrev-ref", "HEAD"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + // Checkout the integration branch + run_git(repo_path, &["checkout", integration_branch])?; + + // Attempt merge with --no-ff + let merge_result = run_git( + repo_path, + &["merge", "--no-ff", task_branch, "-m", commit_message], + ); + + match merge_result { + Ok(_) => { + // Get the merge commit hash + let hash = run_git(repo_path, &["rev-parse", "--short", "HEAD"]) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + // Restore original branch + if !original_branch.is_empty() && original_branch != integration_branch { + let _ = run_git(repo_path, &["checkout", &original_branch]); + } + + tracing::info!( + integration = integration_branch, + task = task_branch, + commit = %hash, + "Merged task branch into integration branch" + ); + + Ok(MergeResult::Success { commit_hash: hash }) + } + Err(_) => { + // Collect conflicting files before aborting + let conflict_files = run_git(repo_path, &["diff", "--name-only", "--diff-filter=U"]) + .map(|s| { + s.lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + + // Abort the merge to restore a clean state + let _ = run_git(repo_path, &["merge", "--abort"]); + + // Restore original branch + if !original_branch.is_empty() && original_branch != integration_branch { + let _ = run_git(repo_path, &["checkout", &original_branch]); + } + + tracing::warn!( + integration = integration_branch, + task = task_branch, + conflicts = ?conflict_files, + "Merge conflict detected — merge aborted" + ); + + Ok(MergeResult::Conflict { files: conflict_files }) + } + } +} + +/// Delete a local branch (only if it has been merged). +/// +/// Uses `-d` (safe delete) first, falls back to `-D` (force) if needed. +pub fn delete_task_branch(repo_path: &Path, branch: &str) -> Result<(), AppError> { + match run_git(repo_path, &["branch", "-d", branch]) { + Ok(_) => Ok(()), + Err(_) => { + // Force delete — the branch may not be fully merged to current HEAD + // but its commits live on the integration branch + run_git(repo_path, &["branch", "-D", branch])?; + Ok(()) + } + } +} + +/// Push a branch to the remote. +pub fn push_integration_branch( + repo_path: &Path, + branch: &str, + remote: &str, +) -> Result<(), AppError> { + run_git_remote(repo_path, &["push", "-u", remote, branch])?; + Ok(()) +} + +/// Delete a remote branch. +pub fn delete_remote_branch( + repo_path: &Path, + branch: &str, + remote: &str, +) -> Result<(), AppError> { + let refspec = format!(":{branch}"); + run_git_remote(repo_path, &["push", remote, &refspec])?; + Ok(()) +} + // ── Staging & commit operations ── /// Commit staged changes in a worktree. @@ -972,6 +1130,59 @@ pub fn unstage_file(worktree_path: &Path, file_path: &str) -> Result<(), AppErro Ok(()) } +/// Discard working-tree changes for a file (git checkout -- ). +/// +/// For untracked files, removes the file from disk instead. +pub fn discard_file(worktree_path: &Path, file_path: &str) -> Result<(), AppError> { + let full = worktree_path.join(file_path); + // Check if the file is untracked + let status = run_git(worktree_path, &["status", "--porcelain", "--", file_path])?; + if status.starts_with("??") { + // Untracked — just delete it + if full.is_dir() { + std::fs::remove_dir_all(&full).map_err(|e| { + AppError::Git(format!("Failed to remove untracked directory {file_path}: {e}")) + })?; + } else { + std::fs::remove_file(&full).map_err(|e| { + AppError::Git(format!("Failed to remove untracked file {file_path}: {e}")) + })?; + } + } else { + // Tracked — restore from HEAD + run_git(worktree_path, &["checkout", "HEAD", "--", file_path])?; + } + Ok(()) +} + +/// Amend the last commit with the currently staged changes. +/// +/// If `message` is provided, replaces the commit message. Otherwise keeps +/// the existing message (`--no-edit`). +pub fn commit_amend(worktree_path: &Path, message: Option<&str>) -> Result { + if let Some(msg) = message { + run_git(worktree_path, &["commit", "--amend", "-m", msg])?; + } else { + run_git(worktree_path, &["commit", "--amend", "--no-edit"])?; + } + let hash = run_git(worktree_path, &["rev-parse", "--short", "HEAD"])?; + Ok(hash.trim().to_string()) +} + +/// Get the subject line of the last commit (for pre-filling amend UI). +pub fn get_last_commit_message(worktree_path: &Path) -> Result { + let output = run_git(worktree_path, &["log", "-1", "--format=%B"])?; + Ok(output.trim().to_string()) +} + +/// Get the diff of staged changes (git diff --cached). +/// +/// Used for AI commit message generation — gives the model context about +/// what is about to be committed. +pub fn get_staged_diff(worktree_path: &Path) -> Result { + run_git(worktree_path, &["diff", "--cached"]) +} + // ── Push / PR operations ── /// Push the current branch to a remote. @@ -1062,16 +1273,10 @@ pub fn get_sync_status(repo_path: &Path) -> Result { Ok(SyncStatus { ahead, behind }) } -/// Pull from origin (fast-forward only). Errors if working tree is dirty. +/// Pull from origin (fast-forward only). +/// Allows pulling with uncommitted changes — Git will abort if dirty files +/// overlap with incoming changes, matching VS Code / Zed behavior. pub fn git_pull(repo_path: &Path) -> Result { - // Check for dirty working tree - let status = run_git(repo_path, &["status", "--porcelain"])?; - if !status.trim().is_empty() { - return Err(AppError::Git( - "Working tree has uncommitted changes. Commit or stash them first.".to_string(), - )); - } - // Fetch run_git_remote(repo_path, &["fetch", "origin"])?; @@ -1082,10 +1287,12 @@ pub fn git_pull(repo_path: &Path) -> Result { let check = run_git(repo_path, &["merge-base", "--is-ancestor", "HEAD", &upstream]); if check.is_err() { return Err(AppError::Git( - "Cannot fast-forward: local branch has diverged from remote. Use manual merge.".to_string(), + "Cannot fast-forward: local branch has diverged from remote. Use manual merge." + .to_string(), )); } + // --ff-only will fail naturally if uncommitted changes conflict with incoming changes run_git_remote(repo_path, &["pull", "--ff-only"])?; Ok(format!("Pulled latest changes on {branch}")) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5ef7820..4906fef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,7 +6,7 @@ mod acp; mod agent; mod commands; mod config_watcher; -mod continuous; +mod queue; mod db; mod error; mod font_detector; @@ -202,7 +202,7 @@ pub fn run() { app.manage(db_state); app.manage(pty::new_state()); - app.manage(continuous::new_state()); + app.manage(queue::new_state()); app.manage(task_watcher::new_state()); app.manage(config_watcher::new_state()); app.manage(acp::state::new_state()); @@ -255,8 +255,12 @@ pub fn run() { commands::git::get_branch_files, commands::git::get_branch_diff, commands::git::commit_staged, + commands::git::commit_amend, commands::git::stage_file, commands::git::unstage_file, + commands::git::discard_file, + commands::git::get_last_commit_message, + commands::git::get_staged_diff, commands::git::push_branch, commands::git::create_pull_request, commands::git::merge_worktree_branch, @@ -363,12 +367,18 @@ pub fn run() { commands::updates::check_for_updates, commands::updates::download_and_install_update, commands::updates::get_app_version, - commands::continuous::start_continuous_mode, - commands::continuous::pause_continuous_mode, - commands::continuous::resume_continuous_mode, - commands::continuous::stop_continuous_mode, - commands::continuous::dismiss_continuous_mode, - commands::continuous::get_continuous_mode_status, + commands::queue::validate_queue_deps, + commands::queue::start_queue_mode, + commands::queue::pause_queue_mode, + commands::queue::resume_queue_mode, + commands::queue::stop_queue_mode, + commands::queue::dismiss_queue_mode, + commands::queue::get_queue_mode_status, + commands::queue::get_integration_branch, + commands::queue::resolve_merge_conflict, + commands::queue::skip_conflicted_task, + commands::queue::cleanup_integration_branch, + commands::queue::push_integration_branch, commands::usage::get_agent_usage, commands::files::list_directory, commands::files::index_project_files, diff --git a/src-tauri/src/mcp/server.rs b/src-tauri/src/mcp/server.rs index 80a975d..1b91215 100644 --- a/src-tauri/src/mcp/server.rs +++ b/src-tauri/src/mcp/server.rs @@ -16,7 +16,7 @@ use tauri::{AppHandle, Emitter, Manager}; use super::protocol::*; use super::tools; use crate::commands::tasks::do_update_task_status; -use crate::continuous::{self, ContinuousState}; +use crate::queue::{self, QueueState}; use crate::db; use crate::db::DbState; use crate::db::models::TaskStatus; @@ -274,6 +274,9 @@ async fn handle_tool_call( "report_waiting" => { handle_report_waiting(session_id, ¶ms.arguments, mcp, app).await } + "get_instructions" => { + handle_get_instructions(session_id, mcp, app).await + } "report_error" => handle_report_error(session_id, ¶ms.arguments, mcp, app).await, "report_complete" => { handle_report_complete(session_id, ¶ms.arguments, mcp, app).await @@ -703,13 +706,13 @@ async fn handle_report_complete( ); }); - // Auto-advance continuous mode: mark item complete + launch next task (chained). + // Auto-advance queue mode: mark item complete + launch next task (chained). // Sessions are NOT stopped — they stay alive so the user can review agent output. - // Only spawn the advance task if this session is actually part of a continuous run. - let cont_state: tauri::State<'_, ContinuousState> = app.state(); - let is_continuous = continuous::find_run_by_session(&cont_state, session_id).await.is_some(); + // Only spawn the advance task if this session is actually part of a queue run. + let queue_state: tauri::State<'_, QueueState> = app.state(); + let is_queued = queue::find_run_by_session(&queue_state, session_id).await.is_some(); - if is_continuous { + if is_queued { let app_clone = app.clone(); let sid = session_id.to_string(); tauri::async_runtime::spawn(async move { @@ -717,7 +720,7 @@ async fn handle_report_complete( // Must run on a blocking thread — mark_complete_and_advance uses // blocking_lock() which panics inside a tokio async context. let _ = tokio::task::spawn_blocking(move || { - continuous::mark_complete_and_advance(&app_clone, &sid); + queue::mark_complete_and_advance(&app_clone, &sid); }).await; }); } @@ -791,7 +794,7 @@ async fn handle_report_researched( } /// Look up the session's linked task and move it to "in-review". -/// Used by report_complete (task/continuous sessions). +/// Used by report_complete (task/queue sessions). /// Errors are logged but not propagated — MCP should always succeed. fn try_mark_task_complete(app: &AppHandle, session_id: &str) { let db: tauri::State<'_, DbState> = app.state(); @@ -936,6 +939,196 @@ async fn resolve_task_id( Err("No task_id provided and no task associated with this session".to_string()) } +// ── get_instructions ── + +async fn handle_get_instructions( + session_id: &str, + mcp: &Arc>, + app: &AppHandle, +) -> McpToolResult { + // 1. Read session context + let (session_mode, task_id, project_id) = { + let guard = mcp.lock().await; + match guard.sessions.get(session_id) { + Some(data) => ( + data.session_mode.clone(), + data.task_id.clone(), + data.project_id.clone(), + ), + None => return McpToolResult::error("Session not found in MCP state"), + } + }; + + let mode = session_mode.as_deref(); + + // 2. Build instructions + let mut instructions = String::with_capacity(4096); + + // -- Status reporting (always) -- + instructions.push_str("# Faber Session Instructions\n\n"); + instructions.push_str("## Status Reporting (required)\n\n"); + instructions.push_str("You MUST use these MCP tools throughout your workflow:\n\n"); + instructions.push_str("- `report_status(status, message, activity?)` — Call FIRST when you start working (status: \"working\"). Call again when your activity changes. Activities: \"researching\", \"exploring\", \"planning\", \"coding\", \"testing\", \"debugging\", \"reviewing\".\n"); + instructions.push_str("- `report_progress(current_step, total_steps, description)` — Call before each major step so the IDE shows a progress bar.\n"); + instructions.push_str("- `report_files_changed(files)` — Call after modifying files so the IDE can track changes.\n"); + instructions.push_str("- `report_error(error, details?)` — Call if you hit a hard blocker. After calling this, STOP and wait for the user.\n"); + instructions.push_str("- `report_waiting(question)` — Call if you need user input. After calling this, STOP and wait — the session pauses until the user responds.\n"); + + // -- Task management (if task-linked) -- + let is_task_linked = matches!(mode, Some("task" | "queue" | "research" | "breakdown")); + if is_task_linked { + instructions.push_str("\n## Task Management\n\n"); + instructions.push_str("- `get_task(task_id?)` — Fetch task metadata and body. Omit task_id for the current session's task.\n"); + instructions.push_str("- `update_task(task_id?, ...)` — Update task metadata and/or body.\n"); + instructions.push_str("- `update_task_plan(plan, task_id?)` — Update the implementation plan section.\n"); + instructions.push_str("- `create_task(title, ...)` — Create a new task (always backlog).\n"); + instructions.push_str("- `list_tasks(status?, label?)` — List tasks with optional filters.\n"); + } + + // -- Mode-specific completion & workflow -- + match mode { + Some("task" | "queue") => { + instructions.push_str("\n## Completing Work\n\n"); + instructions.push_str("- `report_complete(summary)` — Call ONLY ONCE when ALL work is done (code written, tested, verified). This is a terminal action: the task moves to 'in-review' and in queue mode the next task auto-launches. Do NOT call prematurely.\n"); + instructions.push_str("\n## Workflow\n\n"); + instructions.push_str("1. Call `report_status(\"working\", ...)` immediately\n"); + instructions.push_str("2. Read the task details below\n"); + instructions.push_str("3. Call `report_progress(...)` before each step\n"); + instructions.push_str("4. Do the work — call `report_files_changed(...)` after modifying files\n"); + instructions.push_str("5. When ALL work is done and verified, call `report_complete(summary)`\n"); + instructions.push_str("6. If you need user input, call `report_waiting(question)` and STOP\n"); + } + Some("research") => { + instructions.push_str("\n## Completing Research\n\n"); + instructions.push_str("- `report_researched(summary)` — Call when research is complete. Save findings with `update_task_plan` first. The user will decide whether to continue to implementation.\n"); + instructions.push_str("\n## Workflow\n\n"); + instructions.push_str("1. Call `report_status(\"working\", ...)` immediately\n"); + instructions.push_str("2. Read the task details below\n"); + instructions.push_str("3. Research the codebase, explore approaches\n"); + instructions.push_str("4. Save findings using `update_task_plan(plan)`\n"); + instructions.push_str("5. Call `report_researched(summary)` when research is complete\n"); + instructions.push_str("6. If you need user input, call `report_waiting(question)` and STOP\n"); + } + Some("breakdown") => { + instructions.push_str("\n## Completing Breakdown\n\n"); + instructions.push_str("Break the epic into concrete child tasks using `create_task` with the `epic_id` parameter. Present the breakdown plan to the user before creating tasks. There is no `report_complete` tool in breakdown mode — the user will review the created tasks.\n"); + } + _ => { + // vibe, chat, shell — no completion tools, just status reporting + } + } + + // 3. Include task data if task-linked + if let (Some(ref tid), Some(ref pid)) = (&task_id, &project_id) { + let db_state: tauri::State<'_, DbState> = app.state(); + let conn = match db_state.lock() { + Ok(c) => c, + Err(e) => return McpToolResult::error(format!("Failed to lock DB: {e}")), + }; + + match db::tasks::get(&conn, tid, pid) { + Ok(Some(task)) => { + // Read body from disk if available + let disk_enabled = crate::tasks::task_files_enabled(&conn, pid); + let body = if disk_enabled { + task.task_file_path + .as_ref() + .and_then(|p| { + let path = std::path::Path::new(p); + if path.is_file() { + let content = std::fs::read_to_string(path).ok()?; + crate::tasks::parse_task_file(&content, path) + .ok() + .map(|parsed| parsed.body) + } else { + None + } + }) + .unwrap_or_else(|| task.body.clone()) + } else { + task.body.clone() + }; + + instructions.push_str("\n---\n\n# Task Details\n\n"); + instructions.push_str(&format!("- **ID**: {}\n", task.id)); + instructions.push_str(&format!("- **Title**: {}\n", task.title)); + instructions.push_str(&format!("- **Status**: {}\n", task.status)); + instructions.push_str(&format!("- **Priority**: {}\n", task.priority)); + if !task.labels.is_empty() { + instructions.push_str(&format!("- **Labels**: {}\n", task.labels.join(", "))); + } + if !task.depends_on.is_empty() { + instructions.push_str(&format!("- **Depends on**: {}\n", task.depends_on.join(", "))); + } + if let Some(ref branch) = task.branch { + instructions.push_str(&format!("- **Branch**: {}\n", branch)); + } + if let Some(ref issue) = task.github_issue { + instructions.push_str(&format!("- **GitHub Issue**: {}\n", issue)); + } + if let Some(ref pr) = task.github_pr { + instructions.push_str(&format!("- **GitHub PR**: {}\n", pr)); + } + if !body.is_empty() { + instructions.push_str(&format!("\n## Body\n\n{}\n", body)); + } + + // Include queue context if in queue mode + if mode == Some("queue") { + // Fetch sibling ready tasks to give queue context + if let Ok(all_tasks) = db::tasks::list_by_project(&conn, pid) { + let queue_tasks: Vec<&db::models::Task> = all_tasks + .iter() + .filter(|t| t.status == db::models::TaskStatus::Ready || t.status == db::models::TaskStatus::InProgress) + .collect(); + let current_pos = queue_tasks.iter().position(|t| t.id == task.id); + if let Some(pos) = current_pos { + instructions.push_str(&format!( + "\n## Queue Context\n\nThis is task {} of {} in the queue.\n", + pos + 1, + queue_tasks.len() + )); + } + } + } + + // Include epic context if task belongs to an epic + if let Some(ref epic_id) = task.epic_id { + if let Ok(Some(epic)) = db::tasks::get(&conn, epic_id, pid) { + instructions.push_str(&format!( + "\n## Epic Context\n\n- **Epic**: {} — {}\n", + epic.id, epic.title + )); + if let Ok(siblings) = db::tasks::list_by_epic(&conn, pid, epic_id) { + let done = siblings.iter().filter(|t| t.status == db::models::TaskStatus::Done).count(); + instructions.push_str(&format!( + "- **Progress**: {}/{} tasks done\n", + done, + siblings.len() + )); + } + } + } + } + Ok(None) => { + instructions.push_str(&format!("\n---\n\n**Note**: Task {tid} not found.\n")); + } + Err(e) => { + instructions.push_str(&format!("\n---\n\n**Note**: Failed to fetch task: {e}\n")); + } + } + } + + tracing::info!( + session_id, + mode = ?mode, + has_task = task_id.is_some(), + "Instructions requested via MCP" + ); + + McpToolResult::text(instructions) +} + async fn handle_get_task( session_id: &str, args: &Value, @@ -1283,6 +1476,9 @@ async fn handle_update_task( } } + // Handle body update + let new_body = args.get("body").and_then(|v| v.as_str()); + // Validate status if new_status.parse::().is_err() { return McpToolResult::error(format!("Invalid status: {new_status}")); @@ -1324,7 +1520,8 @@ async fn handle_update_task( github_pr: new_github_pr.clone(), }; - match crate::tasks::serialize_task_file(&frontmatter, &parsed.body) { + let body_to_write = new_body.unwrap_or(&parsed.body); + match crate::tasks::serialize_task_file(&frontmatter, body_to_write) { Ok(new_content) => { if let Err(e) = std::fs::write(file_path, new_content) { return McpToolResult::error(format!( @@ -1364,7 +1561,7 @@ async fn handle_update_task( github_pr: new_github_pr.clone(), depends_on: new_depends_on.clone(), labels: new_labels.clone(), - body: task.body.clone(), + body: new_body.map(|b| b.to_string()).unwrap_or_else(|| task.body.clone()), }; let updated = match db::tasks::upsert(&conn, &new_task) { @@ -1911,7 +2108,6 @@ fn remove_mcp_entry(path: &Path) { pub fn write_mcp_config( cwd: &Path, agent_name: &str, - session_mode: Option<&str>, ) -> Result, AppError> { let entry = match build_mcp_entry(agent_name) { Some(e) => e, @@ -1942,7 +2138,7 @@ pub fn write_mcp_config( // Always call this — `write_instruction_file` is idempotent and will // upsert the MCP section into an existing file or create a new one. if let Some(filename) = session::agent_instruction_filename(agent_name) { - session::write_instruction_file(cwd, filename, session_mode); + session::write_instruction_file(cwd, filename); } Ok(Some(config_path)) @@ -1968,7 +2164,7 @@ mod tests { #[test] fn write_mcp_config_claude_code() { let dir = tempfile::tempdir().unwrap(); - let result = write_mcp_config(dir.path(), "claude-code", None).unwrap(); + let result = write_mcp_config(dir.path(), "claude-code").unwrap(); // Result depends on whether sidecar binary exists in dev if let Some(path) = result { assert!(path.exists()); @@ -1986,7 +2182,7 @@ mod tests { #[test] fn write_mcp_config_gemini() { let dir = tempfile::tempdir().unwrap(); - let result = write_mcp_config(dir.path(), "gemini", None).unwrap(); + let result = write_mcp_config(dir.path(), "gemini").unwrap(); if let Some(path) = result { assert!(path.exists()); assert!(path.to_str().unwrap().contains(".gemini")); @@ -1996,14 +2192,14 @@ mod tests { #[test] fn write_mcp_config_shell_skipped() { let dir = tempfile::tempdir().unwrap(); - let result = write_mcp_config(dir.path(), "shell", None).unwrap(); + let result = write_mcp_config(dir.path(), "shell").unwrap(); assert!(result.is_none()); } #[test] fn cleanup_removes_our_entry() { let dir = tempfile::tempdir().unwrap(); - write_mcp_config(dir.path(), "claude-code", None).unwrap(); + write_mcp_config(dir.path(), "claude-code").unwrap(); // Only assert cleanup if sidecar was found and config was written if dir.path().join(".mcp.json").exists() { cleanup_mcp_config(dir.path()); @@ -2033,7 +2229,7 @@ mod tests { .unwrap(); // Write our config — should merge, not overwrite - write_mcp_config(dir.path(), "claude-code", None).unwrap(); + write_mcp_config(dir.path(), "claude-code").unwrap(); let content: Value = serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); diff --git a/src-tauri/src/mcp/tools.rs b/src-tauri/src/mcp/tools.rs index 46b0683..dddad7a 100644 --- a/src-tauri/src/mcp/tools.rs +++ b/src-tauri/src/mcp/tools.rs @@ -8,7 +8,7 @@ enum ToolCategory { Universal, /// Task management tools — available in all sessions (users commonly manage tasks from vibe/chat) TaskManagement, - /// Task completion signal — task and continuous sessions only + /// Task completion signal — task and queue sessions only TaskCompletion, /// Research completion signal — research sessions only ResearchCompletion, @@ -22,11 +22,11 @@ struct ToolEntry { /// Returns tools filtered for the given session mode. /// /// - All modes: universal tools (status, progress, error, waiting, files_changed) + task management -/// - `task` / `continuous`: + `report_complete` +/// - `task` / `queue`: + `report_complete` /// - `research`: + `report_researched` /// - `breakdown` / `vibe` / `chat`: no completion tools pub fn tools_for_mode(session_mode: Option<&str>) -> Vec { - let include_task_completion = matches!(session_mode, Some("task" | "continuous")); + let include_task_completion = matches!(session_mode, Some("task" | "queue")); let include_research_completion = matches!(session_mode, Some("research")); tool_entries() @@ -42,6 +42,21 @@ pub fn tools_for_mode(session_mode: Option<&str>) -> Vec { fn tool_entries() -> Vec { vec![ + ToolEntry { + category: ToolCategory::Universal, + definition: ToolDefinition { + name: "get_instructions".into(), + description: "Get detailed instructions for this session. \ + Call this FIRST before doing any work. \ + Returns session-specific workflow guidance, available tools, \ + and task context (if applicable) so you don't need to call get_task separately." + .into(), + input_schema: json!({ + "type": "object", + "properties": {} + }), + }, + }, ToolEntry { category: ToolCategory::Universal, definition: ToolDefinition { @@ -188,7 +203,7 @@ fn tool_entries() -> Vec { description: "Signal that you have FULLY completed the task. \ IMPORTANT: Only call this ONCE, after ALL work is done — code written, tested, and verified. \ Calling this has permanent side effects: the task status moves to 'in-review' and \ - in continuous mode the next task in the queue is automatically launched. \ + in queue mode the next task in the queue is automatically launched. \ Do NOT call prematurely (e.g. after just reading the task, or before verifying changes). \ If you need user input, use report_waiting instead. \ If you hit a blocker, use report_error instead." @@ -281,8 +296,8 @@ fn tool_entries() -> Vec { category: ToolCategory::TaskManagement, definition: ToolDefinition { name: "update_task".into(), - description: "Update task metadata (status, priority, labels, dependencies, etc.). \ - Does NOT update the markdown body — use update_task_plan for that." + description: "Update task metadata and/or body. \ + Use 'body' to replace the full markdown body, or use update_task_plan to update only the implementation plan section." .into(), input_schema: json!({ "type": "object", @@ -330,6 +345,10 @@ fn tool_entries() -> Vec { "epic_id": { "type": "string", "description": "Parent epic task ID (set to empty string to unassign)" + }, + "body": { + "type": "string", + "description": "Replace the full markdown body of the task. For updating only the implementation plan section, use update_task_plan instead." } } }), @@ -420,10 +439,10 @@ fn tool_entries() -> Vec { mod tests { use super::*; - // Total: 5 universal + 5 task management + report_complete + report_researched = 12 - const TOTAL_TOOLS: usize = 12; - // Universal (5) + task management (5) = 10 - const BASE_TOOLS: usize = 10; + // Total: 6 universal + 5 task management + report_complete + report_researched = 13 + const TOTAL_TOOLS: usize = 13; + // Universal (6) + task management (5) = 11 + const BASE_TOOLS: usize = 11; fn all_tools() -> Vec { tool_entries().into_iter().map(|e| e.definition).collect() @@ -464,8 +483,8 @@ mod tests { } #[test] - fn continuous_mode_gets_base_plus_complete() { - let tools = tools_for_mode(Some("continuous")); + fn queue_mode_gets_base_plus_complete() { + let tools = tools_for_mode(Some("queue")); assert_eq!(tools.len(), BASE_TOOLS + 1); assert!(tools.iter().any(|t| t.name == "report_complete")); } diff --git a/src-tauri/src/project_config.rs b/src-tauri/src/project_config.rs index b8b1230..af406a2 100644 --- a/src-tauri/src/project_config.rs +++ b/src-tauri/src/project_config.rs @@ -63,7 +63,10 @@ pub struct ProjectConfig { #[serde(default)] pub acp: AcpConfig, - #[serde(default = "default_priorities")] + #[serde(default)] + pub queue: QueueConfig, + + #[serde(default = "default_priorities")] pub priorities: Vec, /// Catch-all for unknown keys (forward compatibility). @@ -148,7 +151,7 @@ pub struct GitHubSyncDefaults { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AcpConfig { - /// Trust mode for autonomous operation (e.g. continuous mode). + /// Trust mode for autonomous operation (e.g. queue mode). /// Values: "auto_approve", "normal", "deny_writes" #[serde(default = "default_normal")] pub trust_mode_policy: String, @@ -192,6 +195,20 @@ impl Default for AcpConfig { } } +/// Queue/autonomous mode upstream settings. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct QueueConfig { + /// Push integration branch to remote after run completion. Default: false. + #[serde(default)] + pub auto_push: bool, + + /// Create PR from integration branch → base branch after push. Default: false. + /// Requires `auto_push` to be true. + #[serde(default)] + pub auto_create_pr: bool, +} + impl Default for ProjectConfig { fn default() -> Self { Self { @@ -204,6 +221,7 @@ impl Default for ProjectConfig { task_files_to_disk: true, github: GitHubConfig::default(), acp: AcpConfig::default(), + queue: QueueConfig::default(), priorities: default_priorities(), extra: serde_json::Map::new(), } @@ -400,6 +418,10 @@ pub fn from_db(conn: &Connection, project_id: &str) -> ProjectConfig { .collect(); } + // Queue upstream + cfg.queue.auto_push = db_bool(conn, project_id, "queue_auto_push", false); + cfg.queue.auto_create_pr = db_bool(conn, project_id, "queue_auto_create_pr", false); + // GitHub cfg.github.sync_enabled = db_bool(conn, project_id, "github_sync_enabled", false); cfg.github.auto_close = db_bool(conn, project_id, "github_auto_close", true); @@ -512,6 +534,10 @@ pub fn sync_to_db( .map_err(|e| format!("Create ACP rule: {e}"))?; } + // Queue upstream + set_bool(conn, project_id, "queue_auto_push", cfg.queue.auto_push)?; + set_bool(conn, project_id, "queue_auto_create_pr", cfg.queue.auto_create_pr)?; + // GitHub set_bool(conn, project_id, "github_sync_enabled", cfg.github.sync_enabled)?; set_bool(conn, project_id, "github_auto_close", cfg.github.auto_close)?; @@ -601,6 +627,10 @@ pub fn update_setting( } } + // Queue upstream + "queue_auto_push" => cfg.queue.auto_push = value != "false", + "queue_auto_create_pr" => cfg.queue.auto_create_pr = value != "false", + // Priorities "priorities" => { if let Ok(priorities) = serde_json::from_str::>(value) { @@ -697,6 +727,9 @@ mod tests { assert_eq!(loaded.github.merge_detection, true); assert!(loaded.github.label_mapping.is_empty()); assert_eq!(loaded.github.sync_defaults.title, false); + // Queue defaults + assert_eq!(loaded.queue.auto_push, false); + assert_eq!(loaded.queue.auto_create_pr, false); } #[test] @@ -720,6 +753,7 @@ mod tests { assert!(obj.contains_key("taskFilesToDisk"), "missing taskFilesToDisk"); assert!(obj.contains_key("github"), "missing github"); assert!(obj.contains_key("acp"), "missing acp"); + assert!(obj.contains_key("queue"), "missing queue"); assert!(obj.contains_key("priorities"), "missing priorities"); // GitHub nested keys diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 325c85b..7e863d9 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -271,12 +271,12 @@ fn output_reader( }, ); } - // Check for continuous mode crash (PTY exit without report_complete) - crate::continuous::handle_pty_exit(&app, &session_id); + // Check for queue mode crash (PTY exit without report_complete) + crate::queue::handle_pty_exit(&app, &session_id); // Update DB status to "finished" for natural PTY exit. // Guard: only update if session is still in an active state to avoid - // overwriting a status already set by stop_session or continuous mode. + // overwriting a status already set by stop_session or queue mode. if let Some(db_state) = app.try_state::() { if let Ok(conn) = db_state.inner().lock() { if let Ok(Some(session)) = db::sessions::get(&conn, &session_id) { diff --git a/src-tauri/src/queue.rs b/src-tauri/src/queue.rs new file mode 100644 index 0000000..ba1ebd4 --- /dev/null +++ b/src-tauri/src/queue.rs @@ -0,0 +1,1401 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Mutex as TokioMutex; + +use crate::acp::state::AcpState; +use crate::commands::prompts; +use crate::db; +use crate::db::models::SessionTransport; +use crate::db::DbState; +use crate::error::AppError; +use crate::mcp::McpState; +use crate::pty::PtyState; +use crate::session; + +// ── Types ── + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum QueueStatus { + Running, + Paused, + Completed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum QueueItemStatus { + Pending, + Running, + Completed, + Error, + /// Blocked because a dependency errored — will never run. + Blocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum BranchingStrategy { + Independent, + Chained, + /// Dependency-aware execution: tasks launch when all their in-queue + /// dependencies are complete. Root tasks (no deps) launch immediately + /// in parallel; downstream tasks launch as deps finish. + Dag, +} + +/// Worktree/merge strategy for a queue or autonomous run. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WorktreeStrategy { + /// Auto-merge to a shared integration branch after each task. + /// Worktrees are cleaned after merge. Single branch at end. + Integration, + /// Separate worktrees from base branch, no auto-merge. + /// Current behavior — user manages merge ordering. + Independent, + /// Single worktree, concurrency=1, all tasks commit sequentially. + /// Deprecated: not exposed in UI — kept for potential future use. + #[deprecated(note = "Not exposed in UI — kept for potential future use")] + Sequential, +} + +impl WorktreeStrategy { + #[allow(deprecated)] + pub fn as_str(&self) -> &'static str { + match self { + Self::Integration => "integration", + Self::Independent => "independent", + Self::Sequential => "sequential", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueItem { + pub task_id: String, + pub status: QueueItemStatus, + pub session_id: Option, + pub error: Option, + /// Resolved agent for this task (task-level override or run-level default). + pub agent_name: Option, + /// In-queue dependency task IDs (only populated for DAG strategy). + #[serde(default)] + pub depends_on: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueueRun { + pub project_id: String, + pub status: QueueStatus, + pub queue: Vec, + pub current_index: usize, + pub strategy: BranchingStrategy, + pub base_branch: Option, + pub agent_name: Option, + pub model: Option, + pub last_branch: Option, + /// Transport for session launch (pty or acp). Defaults to pty. + #[serde(default)] + pub transport: SessionTransport, + /// Worktree/merge strategy for this run. + #[serde(default)] + pub worktree_strategy: Option, + /// DB ID of the integration branch record (when using Integration strategy). + #[serde(default)] + pub integration_branch_id: Option, + /// Generated run ID for branch naming. + #[serde(default)] + pub run_id: Option, +} + +pub type QueueState = Arc>>; + +// ── Event payloads ── + +#[derive(Clone, Serialize)] +pub struct QueueModeUpdate { + pub project_id: String, + pub run: QueueRun, +} + +#[derive(Clone, Serialize)] +pub struct QueueModeFinished { + pub project_id: String, + pub completed_count: usize, +} + +// ── Integration branch event payloads ── + +#[derive(Clone, Serialize)] +pub struct TaskMergedEvent { + pub task_id: String, + pub integration_branch: String, + pub merge_commit: String, +} + +#[derive(Clone, Serialize)] +pub struct MergeConflictEvent { + pub task_id: String, + pub integration_branch: String, + pub conflicting_files: Vec, +} + +#[derive(Clone, Serialize)] +pub struct RunCompletedEvent { + pub run_id: String, + pub integration_branch: String, + pub merged_count: usize, +} + +#[derive(Clone, Serialize)] +pub struct IntegrationBranchUpdatedEvent { + pub branch_name: String, + pub merged_count: usize, + pub pending_count: usize, +} + +// ── State constructor ── + +pub fn new_state() -> QueueState { + Arc::new(TokioMutex::new(HashMap::new())) +} + +// ── Lookups ── + +/// Find the project_id for a queue run that contains the given session_id. +pub async fn find_run_by_session( + state: &QueueState, + session_id: &str, +) -> Option { + let guard = state.lock().await; + for (project_id, run) in guard.iter() { + for item in &run.queue { + if item.session_id.as_deref() == Some(session_id) { + return Some(project_id.clone()); + } + } + } + None +} + +// ── Core advance logic ── + +/// Advance the queue run after a session completes. +/// For chained strategy: launch the next task in sequence. +/// For independent strategy: mark as complete, check if all are done. +/// For dag strategy: mark complete, find newly-unblocked tasks, launch them. +pub fn try_advance(app: &AppHandle, project_id: &str) -> Result<(), AppError> { + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mut guard = cont_state.blocking_lock(); + + let run = match guard.get_mut(project_id) { + Some(r) => r, + None => return Ok(()), // no active run + }; + + // Don't advance if paused + if run.status == QueueStatus::Paused { + return Ok(()); + } + + match run.strategy { + BranchingStrategy::Independent => { + // For independent mode, mark the current item as completed. + // The session_id matching happens in mark_complete_and_advance. + // Here we just mark current_index item (set by the caller). + if run.current_index < run.queue.len() { + run.queue[run.current_index].status = QueueItemStatus::Completed; + } + + // Check if all items are finished (completed or error) + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) + }); + + if all_done { + let completed_count = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + run.status = QueueStatus::Completed; + tracing::info!(project_id, completed_count, "Queue mode completed all tasks"); + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.to_string(), + run: run.clone(), + }); + + Ok(()) + } + BranchingStrategy::Chained => { + // Mark current item as completed + if run.current_index < run.queue.len() { + run.queue[run.current_index].status = QueueItemStatus::Completed; + } + + // Move to next + let next_index = run.current_index + 1; + if next_index >= run.queue.len() { + // All done + let completed_count = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + run.status = QueueStatus::Completed; + tracing::info!(project_id, completed_count, "Queue mode completed all tasks"); + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.to_string(), + run: run.clone(), + }); + return Ok(()); + } + + run.current_index = next_index; + run.queue[next_index].status = QueueItemStatus::Running; + + let task_id = run.queue[next_index].task_id.clone(); + tracing::info!(project_id, next_task_id = %task_id, "Queue mode advancing to next task"); + let agent = run.queue[next_index].agent_name.clone(); + let model = run.model.clone(); + let base_branch = run.base_branch.clone(); + let last_branch = run.last_branch.clone(); + let transport = run.transport; + let pid = project_id.to_string(); + let uses_integration = run.worktree_strategy == Some(WorktreeStrategy::Integration); + let integration_branch_name = if uses_integration { + run.integration_branch_id.as_ref().and_then(|ib_id| { + let db_state: tauri::State<'_, DbState> = app.state(); + db_state.lock().ok() + .and_then(|conn| db::integration_branches::get(&conn, ib_id).ok().flatten()) + .map(|ib| ib.branch_name) + }) + } else { + None + }; + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + + // Drop lock before launching session (it acquires DB lock) + drop(guard); + + let launch_base = if let Some(ref ib_name) = integration_branch_name { + Some(ib_name.as_str()) + } else { + last_branch.as_deref().or(base_branch.as_deref()) + }; + + let session = launch_task_for_queue(app, &pid, &task_id, agent.as_deref(), model.as_deref(), launch_base, transport)?; + + let task_branch = if session.worktree_path.is_some() { + let db_state: tauri::State<'_, DbState> = app.state(); + db_state.lock().ok() + .and_then(|conn| db::tasks::get(&conn, &task_id, &pid).ok().flatten()) + .and_then(|t| t.branch) + } else { + None + }; + + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&pid) { + run.queue[next_index].session_id = Some(session.id.clone()); + if let Some(branch) = task_branch { + run.last_branch = Some(branch); + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + } + + Ok(()) + } + BranchingStrategy::Dag => { + // Mark the completed item + if run.current_index < run.queue.len() { + run.queue[run.current_index].status = QueueItemStatus::Completed; + } + let completed_task_id = run.queue.get(run.current_index) + .map(|i| i.task_id.clone()) + .unwrap_or_default(); + + // Find newly-unblocked tasks: pending items whose deps are all completed + let completed_ids: HashSet<&str> = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .map(|i| i.task_id.as_str()) + .collect(); + + let ready_indices: Vec = run.queue.iter().enumerate() + .filter(|(_, item)| { + item.status == QueueItemStatus::Pending + && item.depends_on.iter().all(|dep| completed_ids.contains(dep.as_str())) + }) + .map(|(i, _)| i) + .collect(); + + tracing::info!( + project_id, + completed_task = %completed_task_id, + newly_ready = ready_indices.len(), + "DAG: task completed, checking unblocked tasks" + ); + + // Mark them as running and collect launch info + let mut to_launch: Vec = Vec::new(); + for &idx in &ready_indices { + run.queue[idx].status = QueueItemStatus::Running; + to_launch.push(DagLaunchInfo { + index: idx, + task_id: run.queue[idx].task_id.clone(), + agent: run.queue[idx].agent_name.clone(), + }); + } + + // Check if everything is finished + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error | QueueItemStatus::Blocked) + }); + + if all_done { + let completed_count = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + run.status = QueueStatus::Completed; + tracing::info!(project_id, completed_count, "DAG queue completed all tasks"); + } + + let model = run.model.clone(); + let base_branch = run.base_branch.clone(); + let transport = run.transport; + let pid = project_id.to_string(); + let uses_integration = run.worktree_strategy == Some(WorktreeStrategy::Integration); + let integration_branch_name = if uses_integration { + run.integration_branch_id.as_ref().and_then(|ib_id| { + let db_state: tauri::State<'_, DbState> = app.state(); + db_state.lock().ok() + .and_then(|conn| db::integration_branches::get(&conn, ib_id).ok().flatten()) + .map(|ib| ib.branch_name) + }) + } else { + None + }; + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + + // Drop lock before launching sessions + drop(guard); + + // Launch all newly-ready tasks + for info in &to_launch { + let launch_base = if let Some(ref ib_name) = integration_branch_name { + Some(ib_name.as_str()) + } else { + base_branch.as_deref() + }; + + match launch_task_for_queue(app, &pid, &info.task_id, info.agent.as_deref(), model.as_deref(), launch_base, transport) { + Ok(session) => { + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&pid) { + run.queue[info.index].session_id = Some(session.id.clone()); + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + } + } + Err(e) => { + tracing::error!(task_id = %info.task_id, %e, "DAG: failed to launch task"); + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&pid) { + run.queue[info.index].status = QueueItemStatus::Error; + run.queue[info.index].error = Some(format!("Launch failed: {e}")); + // Block dependents of this failed task + block_dependents(run, &info.task_id); + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + } + } + } + } + + // Re-check completion after all launches (some may have failed → blocked more) + { + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&pid) { + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error | QueueItemStatus::Blocked) + }); + if all_done && run.status != QueueStatus::Completed { + let completed_count = run.queue.iter() + .filter(|i| i.status == QueueItemStatus::Completed) + .count(); + run.status = QueueStatus::Completed; + tracing::info!(project_id = %pid, completed_count, "DAG queue completed (some tasks blocked/errored)"); + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: pid.clone(), + run: run.clone(), + }); + } + } + } + + Ok(()) + } + } +} + +/// Info needed to launch a task in DAG mode (extracted while holding the lock). +struct DagLaunchInfo { + index: usize, + task_id: String, + agent: Option, +} + +/// Block all pending tasks in a queue vec that transitively depend on `errored_task_id`. +/// Used during initial launch before the QueueRun is constructed. +pub fn block_dependents_in_queue(queue: &mut [QueueItem], errored_task_id: &str) { + let mut to_block: Vec = vec![errored_task_id.to_string()]; + while let Some(failed_id) = to_block.pop() { + for item in queue.iter_mut() { + if item.status == QueueItemStatus::Pending + && item.depends_on.iter().any(|d| d == &failed_id) + { + item.status = QueueItemStatus::Blocked; + item.error = Some(format!("Blocked: dependency {} failed", failed_id)); + to_block.push(item.task_id.clone()); + } + } + } +} + +/// Block all pending tasks that transitively depend on `errored_task_id`. +/// Uses BFS to cascade the blocked status through the dependency graph. +fn block_dependents(run: &mut QueueRun, errored_task_id: &str) { + let mut to_block: Vec = vec![errored_task_id.to_string()]; + let mut blocked_count = 0usize; + + while let Some(failed_id) = to_block.pop() { + for item in run.queue.iter_mut() { + if item.status == QueueItemStatus::Pending + && item.depends_on.iter().any(|d| d == &failed_id) + { + item.status = QueueItemStatus::Blocked; + item.error = Some(format!("Blocked: dependency {} failed", failed_id)); + to_block.push(item.task_id.clone()); + blocked_count += 1; + } + } + } + + if blocked_count > 0 { + tracing::info!(errored_task = %errored_task_id, blocked_count, "DAG: blocked dependent tasks"); + } +} + +/// Attempt to auto-merge a completed task's branch into the integration branch. +/// +/// Called when a task completes and the run uses the Integration worktree strategy. +/// Returns `true` if the merge succeeded (or there's no integration branch), +/// `false` if there was a conflict (run should be paused). +fn try_auto_merge( + app: &AppHandle, + project_id: &str, + task_id: &str, + integration_branch_id: &str, +) -> bool { + let db_state: tauri::State<'_, DbState> = app.state(); + + let conn = match db_state.lock() { + Ok(c) => c, + Err(e) => { + tracing::error!(%e, "Failed to lock DB for auto-merge"); + return false; + } + }; + + // Get the integration branch record + let ib = match db::integration_branches::get(&conn, integration_branch_id) { + Ok(Some(ib)) => ib, + Ok(None) => { + tracing::warn!(integration_branch_id, "Integration branch record not found"); + return true; // Don't block on missing record + } + Err(e) => { + tracing::error!(%e, "Failed to get integration branch"); + return false; + } + }; + + // Get the task to find its branch name + let task = match db::tasks::get(&conn, task_id, project_id) { + Ok(Some(t)) => t, + _ => { + tracing::warn!(task_id, "Task not found for auto-merge"); + return true; + } + }; + + let task_branch = match &task.branch { + Some(b) => b.clone(), + None => { + tracing::warn!(task_id, "Task has no branch — skipping merge"); + return true; + } + }; + + // Get the project path + let project = match db::projects::get(&conn, project_id) { + Ok(Some(p)) => p, + _ => { + tracing::error!(project_id, "Project not found for auto-merge"); + return false; + } + }; + + let repo_path = std::path::Path::new(&project.path); + let commit_msg = format!("Merge {}: {}", task_id, task.title); + + // Attempt the merge + match crate::git::merge_task_branch(repo_path, &ib.branch_name, &task_branch, &commit_msg) { + Ok(crate::git::MergeResult::Success { commit_hash }) => { + // Record the successful merge + if let Err(e) = db::integration_branches::record_merge(&conn, &ib.id, task_id) { + tracing::error!(%e, task_id, "Failed to record merge in DB"); + } + + // Auto-advance task to done + let _ = crate::commands::tasks::do_update_task_status( + &conn, project_id, task_id, "done", + ); + + // Clean up the worktree (work preserved on integration branch) + if let Some(wt_path) = &task.worktree_path { + let wt = std::path::Path::new(wt_path); + if let Err(e) = crate::git::delete_worktree(repo_path, wt) { + tracing::warn!(%e, task_id, worktree = wt_path, "Failed to cleanup worktree after merge"); + } else { + // Clear worktree path from task + let _ = db::tasks::update_worktree(&conn, task_id, project_id, None); + tracing::info!(task_id, worktree = wt_path, "Cleaned up worktree after merge"); + } + } + + // Delete the task branch (work lives on integration branch) + if let Err(e) = crate::git::delete_task_branch(repo_path, &task_branch) { + tracing::warn!(%e, task_id, branch = %task_branch, "Failed to delete task branch after merge"); + } + + // Emit events + let _ = app.emit("task-merged", TaskMergedEvent { + task_id: task_id.to_string(), + integration_branch: ib.branch_name.clone(), + merge_commit: commit_hash, + }); + + // Get updated counts for the update event + if let Ok(Some(updated_ib)) = db::integration_branches::get(&conn, &ib.id) { + let _ = app.emit("integration-branch-updated", IntegrationBranchUpdatedEvent { + branch_name: updated_ib.branch_name, + merged_count: updated_ib.merged_tasks.len(), + pending_count: updated_ib.pending_tasks.len(), + }); + } + + tracing::info!(task_id, "Auto-merged task branch into integration branch"); + true + } + Ok(crate::git::MergeResult::Conflict { files }) => { + // Record the conflict + if let Err(e) = db::integration_branches::record_conflict(&conn, &ib.id, task_id, &files) { + tracing::error!(%e, task_id, "Failed to record merge conflict in DB"); + } + + // Emit conflict event + let _ = app.emit("merge-conflict", MergeConflictEvent { + task_id: task_id.to_string(), + integration_branch: ib.branch_name.clone(), + conflicting_files: files, + }); + + tracing::warn!(task_id, "Merge conflict — run paused"); + false + } + Err(e) => { + tracing::error!(%e, task_id, "Auto-merge failed unexpectedly"); + false + } + } +} + +/// Check if a completed run should trigger upstream actions and emit completion. +fn check_run_completion( + app: &AppHandle, + _project_id: &str, + run: &QueueRun, +) { + let ib_id = match &run.integration_branch_id { + Some(id) => id.clone(), + None => return, + }; + + let run_id = run.run_id.clone().unwrap_or_default(); + + let db_state: tauri::State<'_, DbState> = app.state(); + let conn = match db_state.lock() { + Ok(c) => c, + Err(_) => return, + }; + + // Mark integration branch as completed + let _ = db::integration_branches::mark_completed(&conn, &ib_id); + + // Get updated state + if let Ok(Some(ib)) = db::integration_branches::get(&conn, &ib_id) { + let _ = app.emit("run-completed", RunCompletedEvent { + run_id, + integration_branch: ib.branch_name, + merged_count: ib.merged_tasks.len(), + }); + } +} + +/// Mark the completed session's queue item and advance the queue. +/// Called from the MCP report_complete handler after a delay. +/// Sessions are NOT stopped here — they stay alive so the user can review +/// agent summaries. The user dismisses them via the queue mode bar. +pub fn mark_complete_and_advance(app: &AppHandle, session_id: &str) { + let cont_state: tauri::State<'_, QueueState> = app.state(); + let (project_id, item_index, task_id, uses_integration, ib_id) = { + let guard = cont_state.blocking_lock(); + let mut found = None; + for (pid, run) in guard.iter() { + for (i, item) in run.queue.iter().enumerate() { + if item.session_id.as_deref() == Some(session_id) { + found = Some(( + pid.clone(), + i, + item.task_id.clone(), + run.worktree_strategy == Some(WorktreeStrategy::Integration), + run.integration_branch_id.clone(), + )); + break; + } + } + if found.is_some() { break; } + } + match found { + Some(f) => f, + None => return, + } + }; + + // If using integration strategy, attempt auto-merge before advancing + if uses_integration { + if let Some(ref ib_id) = ib_id { + let merge_ok = try_auto_merge(app, &project_id, &task_id, ib_id); + if !merge_ok { + // Merge conflict — pause the run + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + run.status = QueueStatus::Paused; + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + return; + } + } + } + + // Set current_index to the completed item so try_advance marks the right one + { + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + run.current_index = item_index; + } + } + + // Advance to next task (or check if all done for independent mode) + if let Err(e) = try_advance(app, &project_id) { + tracing::error!(%e, session_id, "Failed to advance queue mode after session"); + // Pause on error + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + run.status = QueueStatus::Paused; + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + return; + } + + // Check if the run just completed (all tasks done) + { + let guard = cont_state.blocking_lock(); + if let Some(run) = guard.get(&project_id) { + if run.status == QueueStatus::Completed && uses_integration { + check_run_completion(app, &project_id, run); + } + } + } +} + +/// Handle PTY exit for a session that's part of a queue run. +/// If the agent didn't call report_complete, this is a crash. +/// For chained mode: pause the run. +/// For independent mode: mark the item as error but continue others; finish if all done. +pub fn handle_pty_exit(app: &AppHandle, session_id: &str) { + let cont_state: tauri::State<'_, QueueState> = app.state(); + let mcp_state: tauri::State<'_, Arc>> = app.state(); + + // Check if this session is part of a queue run + let project_id = { + let guard = cont_state.blocking_lock(); + let mut found = None; + for (pid, run) in guard.iter() { + if run.status != QueueStatus::Running { continue; } + for item in &run.queue { + if item.session_id.as_deref() == Some(session_id) + && item.status == QueueItemStatus::Running + { + found = Some(pid.clone()); + break; + } + } + if found.is_some() { break; } + } + found + }; + + let Some(project_id) = project_id else { return }; + + // Check if MCP got a completion for this session + let completed = { + let guard = mcp_state.blocking_lock(); + guard.sessions.get(session_id) + .map(|d| d.completed) + .unwrap_or(false) + }; + + if completed { + // Normal exit after report_complete — auto-advance is already scheduled + return; + } + + // Agent crashed without reporting complete — mark error + tracing::warn!(session_id, "PTY exited without report_complete in queue run"); + + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + // Mark the specific queue item as error + for item in &mut run.queue { + if item.session_id.as_deref() == Some(session_id) { + item.status = QueueItemStatus::Error; + item.error = Some("Agent exited without completing".to_string()); + break; + } + } + + match run.strategy { + BranchingStrategy::Independent => { + // Check if all items are finished (completed or error) + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) + }); + + if all_done { + run.status = QueueStatus::Completed; + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + BranchingStrategy::Chained => { + // Chained mode: pause on any error + run.status = QueueStatus::Paused; + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + BranchingStrategy::Dag => { + // Block dependents of the crashed task, then check completion + let errored_task_id = run.queue.iter() + .find(|i| i.session_id.as_deref() == Some(session_id)) + .map(|i| i.task_id.clone()); + + if let Some(ref tid) = errored_task_id { + block_dependents(run, tid); + } + + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error | QueueItemStatus::Blocked) + }); + + if all_done { + run.status = QueueStatus::Completed; + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + } + } +} + +/// Handle manual session stop. +/// For chained mode: pause the entire run. +/// For independent/dag mode: mark item as error, check if all done, continue otherwise. +pub fn handle_manual_stop(app: &AppHandle, session_id: &str) { + let cont_state: tauri::State<'_, QueueState> = app.state(); + + let project_id = { + let guard = cont_state.blocking_lock(); + let mut found = None; + for (pid, run) in guard.iter() { + if run.status != QueueStatus::Running { continue; } + for item in &run.queue { + if item.session_id.as_deref() == Some(session_id) { + found = Some(pid.clone()); + break; + } + } + if found.is_some() { break; } + } + found + }; + + let Some(project_id) = project_id else { return }; + + tracing::info!(session_id, "Queue mode: manual session stop"); + + let mut guard = cont_state.blocking_lock(); + if let Some(run) = guard.get_mut(&project_id) { + // Mark the stopped item + let stopped_task_id = run.queue.iter() + .find(|i| i.session_id.as_deref() == Some(session_id)) + .map(|i| i.task_id.clone()); + + for item in &mut run.queue { + if item.session_id.as_deref() == Some(session_id) { + item.status = QueueItemStatus::Error; + item.error = Some("Manually stopped".to_string()); + break; + } + } + + match run.strategy { + BranchingStrategy::Independent => { + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error) + }); + + if all_done { + run.status = QueueStatus::Completed; + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + BranchingStrategy::Chained => { + run.status = QueueStatus::Paused; + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + BranchingStrategy::Dag => { + // Block dependents of the stopped task + if let Some(ref tid) = stopped_task_id { + block_dependents(run, tid); + } + + let all_done = run.queue.iter().all(|i| { + matches!(i.status, QueueItemStatus::Completed | QueueItemStatus::Error | QueueItemStatus::Blocked) + }); + + if all_done { + run.status = QueueStatus::Completed; + } + + let _ = app.emit("queue-mode-update", QueueModeUpdate { + project_id: project_id.clone(), + run: run.clone(), + }); + } + } + } +} + +// ── Dependency validation ── + +/// Result of dependency graph validation — includes sorted order and strategy hints. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidatedOrder { + /// Task IDs in execution order (dependencies before dependents). + pub sorted_ids: Vec, + /// Whether any in-queue dependencies exist. + pub has_deps: bool, + /// Number of in-queue dependency links. + pub dep_count: usize, + /// Whether the dependency graph forms a simple linear chain. + pub is_chain: bool, + /// Suggested branching strategy (null if no deps). + pub suggestion: Option, + /// Human-readable reason for the suggestion. + pub reason: String, +} + +/// Validate the dependency graph for a set of queue tasks and return a +/// topologically sorted execution order. +/// +/// Checks performed: +/// 1. All `depends_on` references point to task IDs that exist in the DB. +/// 2. External deps (references to tasks outside the queue) must already be +/// "done" — otherwise the dependent task can't run yet. +/// 3. No circular dependencies among the queued tasks. +/// +/// On success returns `ValidatedOrder` with task IDs sorted so that every task +/// appears after its in-queue dependencies. +pub fn validate_dependency_graph( + conn: &rusqlite::Connection, + project_id: &str, + task_ids: &[String], +) -> Result { + let queued: HashSet<&str> = task_ids.iter().map(|s| s.as_str()).collect(); + + // Collect each queued task's in-queue deps and validate external deps. + // Uses owned Strings to avoid lifetime issues with task lookups. + let mut in_queue_deps: HashMap> = HashMap::new(); + for tid in task_ids { + let task = db::tasks::get(conn, tid, project_id)? + .ok_or_else(|| AppError::NotFound(format!("Task {tid}")))?; + + let mut deps_in_queue = Vec::new(); + for dep_id in &task.depends_on { + if queued.contains(dep_id.as_str()) { + deps_in_queue.push(dep_id.clone()); + } else { + // External dependency — must exist and be done + let dep_task = db::tasks::get(conn, dep_id, project_id)? + .ok_or_else(|| { + AppError::Validation(format!( + "Task {tid} depends on {dep_id}, which does not exist" + )) + })?; + if dep_task.status.as_str() != "done" { + return Err(AppError::Validation(format!( + "Task {tid} depends on {dep_id} (not in queue), which is not done (status: {})", + dep_task.status + ))); + } + } + } + in_queue_deps.insert(tid.clone(), deps_in_queue); + } + + // Topological sort with cycle detection (Kahn's algorithm) + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + // Reverse adjacency: dep → list of tasks that depend on it + let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new(); + + for tid in task_ids { + in_degree.entry(tid.as_str()).or_insert(0); + } + for (tid, deps) in &in_queue_deps { + *in_degree.entry(tid.as_str()).or_insert(0) += deps.len(); + for dep in deps { + dependents.entry(dep.as_str()).or_default().push(tid.as_str()); + } + } + + let mut queue_bfs: Vec<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + // Sort for deterministic output + queue_bfs.sort(); + + let mut sorted: Vec = Vec::with_capacity(task_ids.len()); + while let Some(node) = queue_bfs.pop() { + sorted.push(node.to_string()); + if let Some(children) = dependents.get(node) { + for &child in children { + if let Some(deg) = in_degree.get_mut(child) { + *deg -= 1; + if *deg == 0 { + queue_bfs.push(child); + // Re-sort to keep deterministic + queue_bfs.sort(); + } + } + } + } + } + + if sorted.len() != task_ids.len() { + // Some nodes never reached in-degree 0 → cycle + let mut stuck: Vec<&str> = in_degree + .iter() + .filter(|(_, °)| deg > 0) + .map(|(&id, _)| id) + .collect(); + stuck.sort(); + return Err(AppError::Validation(format!( + "Circular dependency detected among tasks: {}", + stuck.join(", ") + ))); + } + + // Compute strategy suggestion metadata + let dep_count: usize = in_queue_deps.values().map(|d| d.len()).sum(); + let has_deps = dep_count > 0; + + // Check if it forms a linear chain: each task has at most 1 dep, + // and no task is depended on by more than one other task + let is_chain = if has_deps { + let all_single = in_queue_deps.values().all(|d| d.len() <= 1); + let mut dep_fan_in: HashMap<&str, usize> = HashMap::new(); + for deps in in_queue_deps.values() { + for d in deps { + *dep_fan_in.entry(d.as_str()).or_insert(0) += 1; + } + } + all_single && dep_fan_in.values().all(|&c| c <= 1) + } else { + false + }; + + let (suggestion, reason) = if !has_deps { + (None, String::new()) + } else { + let links = if dep_count == 1 { "link" } else { "links" }; + if is_chain { + ( + Some(BranchingStrategy::Dag), + format!("{dep_count} dependency {links} found \u{2014} tasks will run in dependency order"), + ) + } else { + ( + Some(BranchingStrategy::Dag), + format!("{dep_count} dependency {links} detected \u{2014} independent tasks will run in parallel"), + ) + } + }; + + Ok(ValidatedOrder { + sorted_ids: sorted, + has_deps, + dep_count, + is_chain, + suggestion, + reason, + }) +} + +// ── Internal helpers ── + +/// Launch a task session for queue mode. +fn launch_task_for_queue( + app: &AppHandle, + project_id: &str, + task_id: &str, + agent: Option<&str>, + model: Option<&str>, + base_branch: Option<&str>, + transport: SessionTransport, +) -> Result { + let db_state: tauri::State<'_, DbState> = app.state(); + let mcp_state: tauri::State<'_, Arc>> = app.state(); + + // Get MCP port BEFORE acquiring DB lock to avoid nested mutex contention + let mcp_port = session::get_mcp_port(&mcp_state); + let conn = db_state.lock().map_err(|e| AppError::Database(e.to_string()))?; + + let template = prompts::get_session_prompt(&conn, "queue"); + let mut vars = HashMap::new(); + vars.insert("task_id", task_id); + vars.insert("mode", "chained"); + let user_prompt = Some(session::interpolate_vars(&template.prompt, &vars)); + + match transport { + SessionTransport::Acp => { + let acp_state: tauri::State<'_, AcpState> = app.state(); + let opts = session::AcpTaskSessionOpts { + task_id, + agent_name: agent, + model, + create_worktree: true, + base_branch, + user_prompt: user_prompt.as_deref(), + is_trust_mode: true, + }; + session::start_acp_task_session(&conn, app, &mcp_state, &acp_state, mcp_port, project_id, &opts) + } + SessionTransport::Pty => { + let pty_state: tauri::State<'_, PtyState> = app.state(); + session::start_task_session( + &conn, + &pty_state, + app, + &mcp_state, + mcp_port, + project_id, + task_id, + agent, + model, + true, // always create worktree + base_branch, + user_prompt.as_deref(), + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db; + use crate::db::models::{NewProject, NewTask, TaskStatus}; + use crate::db::projects; + use crate::db::tasks; + + fn setup() -> (rusqlite::Connection, String) { + let state = db::init_memory().unwrap(); + let conn = state.into_inner().unwrap(); + projects::create( + &conn, + &NewProject { + name: "test".into(), + path: "/tmp/test".into(), + default_agent: None, + default_model: None, + branch_naming_pattern: None, + instruction_file_path: None, + }, + ) + .unwrap(); + let pid = projects::list(&conn).unwrap()[0].id.clone(); + (conn, pid) + } + + fn insert_task( + conn: &rusqlite::Connection, + id: &str, + pid: &str, + status: Option, + depends_on: Vec, + ) { + tasks::upsert( + conn, + &NewTask { + id: id.into(), + project_id: pid.into(), + task_file_path: None, + title: format!("Task {id}"), + status, + priority: None, + task_type: None, + epic_id: None, + agent: None, + model: None, + branch: None, + worktree_path: None, + github_issue: None, + github_pr: None, + depends_on, + labels: vec![], + body: String::new(), + }, + ) + .unwrap(); + } + + #[test] + fn no_deps_returns_all_tasks() { + let (conn, pid) = setup(); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec![]); + insert_task(&conn, "T-2", &pid, Some(TaskStatus::Ready), vec![]); + insert_task(&conn, "T-3", &pid, Some(TaskStatus::Ready), vec![]); + + let ids = vec!["T-1".into(), "T-2".into(), "T-3".into()]; + let result = validate_dependency_graph(&conn, &pid, &ids).unwrap(); + assert_eq!(result.sorted_ids.len(), 3); + assert!(!result.has_deps); + assert_eq!(result.dep_count, 0); + assert!(result.suggestion.is_none()); + } + + #[test] + fn linear_chain_sorted_correctly() { + let (conn, pid) = setup(); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec![]); + insert_task(&conn, "T-2", &pid, Some(TaskStatus::Ready), vec!["T-1".into()]); + insert_task(&conn, "T-3", &pid, Some(TaskStatus::Ready), vec!["T-2".into()]); + + // Pass in reverse order — should still sort correctly + let ids = vec!["T-3".into(), "T-1".into(), "T-2".into()]; + let result = validate_dependency_graph(&conn, &pid, &ids).unwrap(); + assert_eq!(result.sorted_ids, vec!["T-1", "T-2", "T-3"]); + assert!(result.has_deps); + assert_eq!(result.dep_count, 2); + assert!(result.is_chain); + assert_eq!(result.suggestion, Some(BranchingStrategy::Dag)); + } + + #[test] + fn cycle_detected() { + let (conn, pid) = setup(); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec!["T-2".into()]); + insert_task(&conn, "T-2", &pid, Some(TaskStatus::Ready), vec!["T-1".into()]); + + let ids = vec!["T-1".into(), "T-2".into()]; + let err = validate_dependency_graph(&conn, &pid, &ids).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Circular dependency"), "got: {msg}"); + } + + #[test] + fn external_dep_done_is_ok() { + let (conn, pid) = setup(); + insert_task(&conn, "T-ext", &pid, Some(TaskStatus::Done), vec![]); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec!["T-ext".into()]); + insert_task(&conn, "T-2", &pid, Some(TaskStatus::Ready), vec![]); + + // T-ext is NOT in the queue but is done — should pass + let ids = vec!["T-1".into(), "T-2".into()]; + let result = validate_dependency_graph(&conn, &pid, &ids).unwrap(); + assert_eq!(result.sorted_ids.len(), 2); + } + + #[test] + fn external_dep_not_done_errors() { + let (conn, pid) = setup(); + insert_task(&conn, "T-ext", &pid, Some(TaskStatus::Ready), vec![]); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec!["T-ext".into()]); + + let ids = vec!["T-1".into()]; + let err = validate_dependency_graph(&conn, &pid, &ids).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("not done"), "got: {msg}"); + } + + #[test] + fn nonexistent_dep_errors() { + let (conn, pid) = setup(); + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec!["T-ghost".into()]); + + let ids = vec!["T-1".into()]; + let err = validate_dependency_graph(&conn, &pid, &ids).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("does not exist"), "got: {msg}"); + } + + #[test] + fn diamond_deps_sorted_correctly() { + let (conn, pid) = setup(); + // T-1 → T-2, T-1 → T-3, T-2 → T-4, T-3 → T-4 + insert_task(&conn, "T-1", &pid, Some(TaskStatus::Ready), vec![]); + insert_task(&conn, "T-2", &pid, Some(TaskStatus::Ready), vec!["T-1".into()]); + insert_task(&conn, "T-3", &pid, Some(TaskStatus::Ready), vec!["T-1".into()]); + insert_task(&conn, "T-4", &pid, Some(TaskStatus::Ready), vec!["T-2".into(), "T-3".into()]); + + let ids = vec!["T-4".into(), "T-2".into(), "T-3".into(), "T-1".into()]; + let result = validate_dependency_graph(&conn, &pid, &ids).unwrap(); + // T-1 must come before T-2 and T-3, and T-4 must be last + let pos = |id: &str| result.sorted_ids.iter().position(|s| s == id).unwrap(); + assert!(pos("T-1") < pos("T-2")); + assert!(pos("T-1") < pos("T-3")); + assert!(pos("T-2") < pos("T-4")); + assert!(pos("T-3") < pos("T-4")); + // Diamond is NOT a chain (T-4 depends on 2 tasks) + assert!(!result.is_chain); + assert_eq!(result.dep_count, 4); + assert_eq!(result.suggestion, Some(BranchingStrategy::Dag)); + } + + // ── block_dependents_in_queue tests ── + + fn make_item(task_id: &str, deps: Vec<&str>) -> QueueItem { + QueueItem { + task_id: task_id.to_string(), + status: QueueItemStatus::Pending, + session_id: None, + error: None, + agent_name: None, + depends_on: deps.into_iter().map(|s| s.to_string()).collect(), + } + } + + #[test] + fn block_direct_dependents() { + let mut queue = vec![ + { let mut i = make_item("T-1", vec![]); i.status = QueueItemStatus::Error; i }, + make_item("T-2", vec!["T-1"]), + make_item("T-3", vec![]), + ]; + + block_dependents_in_queue(&mut queue, "T-1"); + assert_eq!(queue[1].status, QueueItemStatus::Blocked); + assert_eq!(queue[2].status, QueueItemStatus::Pending); // unrelated + } + + #[test] + fn block_transitive_dependents() { + // T-1 → T-2 → T-3 + let mut queue = vec![ + { let mut i = make_item("T-1", vec![]); i.status = QueueItemStatus::Error; i }, + make_item("T-2", vec!["T-1"]), + make_item("T-3", vec!["T-2"]), + make_item("T-4", vec![]), + ]; + + block_dependents_in_queue(&mut queue, "T-1"); + assert_eq!(queue[1].status, QueueItemStatus::Blocked); + assert_eq!(queue[2].status, QueueItemStatus::Blocked); // transitive + assert_eq!(queue[3].status, QueueItemStatus::Pending); // unrelated + } + + #[test] + fn block_diamond_dependents() { + // T-1 → T-2, T-1 → T-3, T-2+T-3 → T-4 + let mut queue = vec![ + { let mut i = make_item("T-1", vec![]); i.status = QueueItemStatus::Error; i }, + make_item("T-2", vec!["T-1"]), + make_item("T-3", vec!["T-1"]), + make_item("T-4", vec!["T-2", "T-3"]), + ]; + + block_dependents_in_queue(&mut queue, "T-1"); + assert_eq!(queue[1].status, QueueItemStatus::Blocked); + assert_eq!(queue[2].status, QueueItemStatus::Blocked); + assert_eq!(queue[3].status, QueueItemStatus::Blocked); + } +} diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index 6b4df11..b406c89 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -14,7 +14,7 @@ use crate::acp::state::{AcpSessionState, AcpState}; use crate::acp::types::{AcpConfigOptionUpdatePayload, AcpErrorPayload, AcpPromptCompletePayload, EVENT_ACP_CONFIG_OPTION_UPDATE, EVENT_ACP_ERROR, EVENT_ACP_PROMPT_COMPLETE}; use crate::agent::{self, AgentLaunchConfig}; use crate::commands::tasks::do_update_task_status; -use crate::continuous; +use crate::queue; use crate::db; use crate::db::models::{ NewSession, Session, SessionMode, SessionStatus, SessionTransport, @@ -27,125 +27,29 @@ use crate::mcp::server::McpSessionData; use crate::pty::{self, PtyState}; use crate::tasks; -// ── MCP tool descriptions (shared between system prompt and instruction files) ── - -/// Universal status tools description — available in all session modes. -const MCP_TOOLS_UNIVERSAL: &str = "\ -You have MCP tools provided by the Faber IDE for reporting your progress. \ -You MUST use them throughout your workflow. - -## Status Reporting (required) - -- `report_status(status, message, activity?)` — Call FIRST when you start working (status: \"working\"). Call again when your activity changes. Activity options: \"researching\", \"exploring\", \"planning\", \"coding\", \"testing\", \"debugging\", \"reviewing\". -- `report_progress(current_step, total_steps, description)` — Call before each major step so the IDE shows a progress bar. -- `report_files_changed(files)` — Call after modifying files so the IDE can track changes. -- `report_error(error, details?)` — Call if you hit a hard blocker (build failure, missing dependency, etc.). After calling this, STOP and wait for the user. -- `report_waiting(question)` — Call if you need user input or a decision. After calling this, STOP and wait — the session pauses until the user responds."; - -/// Task management tools description — only for task-linked sessions. -const MCP_TOOLS_TASK: &str = " - -## Task Management - -- `get_task(task_id?)` — Fetch task metadata and body. Omit task_id to get the current session's task. Call this first to understand what you need to work on. -- `update_task(task_id?, status?, priority?, title?, labels?, depends_on?, github_issue?, github_pr?)` — Update task metadata (status, priority, labels, etc.). -- `update_task_plan(plan, task_id?)` — Update the implementation plan in the task file. -- `create_task(title, body?, priority?, labels?, depends_on?)` — Create a new task (always created as backlog). -- `list_tasks(status?, label?)` — List all tasks in the current project with optional filters."; - -/// Task completion tool description — only for task/continuous sessions. -const MCP_TOOLS_COMPLETION: &str = " - -## Completing Work - -- `report_complete(summary)` — Call ONLY ONCE when ALL work is done (code written, tested, verified). \ -This is a terminal action: the task moves to 'in-review' and in continuous mode the next task auto-launches. \ -Do NOT call prematurely. If you need input, use `report_waiting`. If blocked, use `report_error`."; - -/// Research session completion guidance — uses report_researched instead of report_complete. -const MCP_TOOLS_RESEARCH_LIFECYCLE: &str = " - -## Completing Research - -- `report_researched(summary)` — Call when your research and analysis is complete. \ -Make sure to save your findings using `update_task_plan` before calling this. \ -The user will be prompted to decide whether to continue to implementation."; - -/// Breakdown session guidance — used instead of MCP_TOOLS_COMPLETION for breakdown mode. -const MCP_TOOLS_BREAKDOWN_LIFECYCLE: &str = " - -## Completing Breakdown - -Break the epic into concrete child tasks using `create_task` with the `epic_id` parameter. \ -Present the breakdown plan to the user before creating tasks. \ -There is no `report_complete` tool in breakdown mode — the user will review the created tasks."; - -/// Build the lifecycle instructions section for a given session mode. -const MCP_TOOLS_LIFECYCLE_TASK: &str = " - -## Workflow - -1. Call `report_status(\"working\", ...)` immediately when you begin -2. Call `get_task()` to fetch the task details -3. Call `report_progress(...)` before each step -4. Do the work — call `report_files_changed(...)` after modifying files -5. When ALL work is done and verified, call `report_complete(summary)` -6. If you need user input at any point, call `report_waiting(question)` and STOP"; - -const MCP_TOOLS_LIFECYCLE_RESEARCH: &str = " - -## Workflow - -1. Call `report_status(\"working\", ...)` immediately when you begin -2. Call `get_task()` to fetch the task details -3. Research the codebase, explore approaches, and discuss with the user -4. Save your findings using `update_task_plan(plan)` -5. Call `report_researched(summary)` when research is complete -6. If you need user input at any point, call `report_waiting(question)` and STOP"; - // ── Agent instruction file management ── const MCP_INSTRUCTION_MARKER_START: &str = ""; const MCP_INSTRUCTION_MARKER_END: &str = ""; -/// Build the MCP tools description for a given session mode. -fn mcp_tools_text(session_mode: Option<&str>) -> String { - let mut text = MCP_TOOLS_UNIVERSAL.to_string(); - - let is_task_linked = matches!( - session_mode, - Some("task" | "continuous" | "research" | "breakdown") - ); - let is_task_completion = matches!(session_mode, Some("task" | "continuous")); - - if is_task_linked { - text.push_str(MCP_TOOLS_TASK); - } - - if is_task_completion { - text.push_str(MCP_TOOLS_COMPLETION); - text.push_str(MCP_TOOLS_LIFECYCLE_TASK); - } else if session_mode == Some("research") { - text.push_str(MCP_TOOLS_RESEARCH_LIFECYCLE); - text.push_str(MCP_TOOLS_LIFECYCLE_RESEARCH); - } else if session_mode == Some("breakdown") { - text.push_str(MCP_TOOLS_BREAKDOWN_LIFECYCLE); - } - - text -} +/// Static instruction pointing agents to the get_instructions MCP tool. +/// This never changes between sessions, so instruction files stay clean in git. +const MCP_INSTRUCTION_CONTENT: &str = "\ +You have MCP tools provided by the Faber IDE. \ +IMPORTANT: Call the `get_instructions` MCP tool FIRST before doing any work. \ +It provides your session-specific workflow, available tools, and task context."; /// Build the MCP system prompt string (for agents that support --system-prompt). -fn mcp_system_prompt_text(session_mode: Option<&str>) -> String { - mcp_tools_text(session_mode) +fn mcp_system_prompt_text() -> String { + MCP_INSTRUCTION_CONTENT.to_string() } /// Build the MCP instruction section for agent instruction files (CLAUDE.md, etc.). -fn mcp_instruction_section(session_mode: Option<&str>) -> String { +fn mcp_instruction_section() -> String { format!( "{}\n## Faber Integration\n\n{}\n{}", MCP_INSTRUCTION_MARKER_START, - mcp_tools_text(session_mode), + MCP_INSTRUCTION_CONTENT, MCP_INSTRUCTION_MARKER_END ) } @@ -192,10 +96,11 @@ pub fn upsert_mcp_section(content: &str, section: &str) -> String { } /// Write or update the MCP section in a single instruction file. -pub fn write_instruction_file(dir: &Path, filename: &str, session_mode: Option<&str>) { +/// Content is static (just points to get_instructions), so the file only changes on first write. +pub fn write_instruction_file(dir: &Path, filename: &str) { let path = dir.join(filename); let existing = std::fs::read_to_string(&path).unwrap_or_default(); - let section = mcp_instruction_section(session_mode); + let section = mcp_instruction_section(); let updated = upsert_mcp_section(&existing, §ion); if updated != existing { let _ = std::fs::write(&path, &updated); @@ -225,9 +130,9 @@ pub(crate) struct SessionStatusChanged { } /// Returns the MCP system prompt if the agent supports the system prompt flag. -fn mcp_system_prompt(adapter: &dyn agent::AgentAdapter, mcp_connected: bool, session_mode: Option<&str>) -> Option { +fn mcp_system_prompt(adapter: &dyn agent::AgentAdapter, mcp_connected: bool) -> Option { if mcp_connected && adapter.supports_system_prompt_flag() { - Some(mcp_system_prompt_text(session_mode)) + Some(mcp_system_prompt_text()) } else { None } @@ -269,7 +174,7 @@ fn inject_mcp( return None; } - match mcp::server::write_mcp_config(cwd, agent_name, session_mode) { + match mcp::server::write_mcp_config(cwd, agent_name) { Ok(Some(_)) => { let mut guard = mcp_state.blocking_lock(); guard.sessions.insert(session_id.to_string(), McpSessionData { @@ -413,7 +318,7 @@ pub fn start_task_session( } let launch_config = AgentLaunchConfig { - system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some(), Some("task")), + system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some()), prompt: user_prompt_str, model: model.clone(), extra_flags, @@ -572,7 +477,7 @@ pub fn start_vibe_session( } let launch_config = AgentLaunchConfig { - system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some(), Some("vibe")), + system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some()), prompt: user_prompt_str, model: model.clone(), extra_flags, @@ -702,7 +607,7 @@ pub fn start_research_session( } let launch_config = AgentLaunchConfig { - system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some(), Some("research")), + system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some()), prompt: user_prompt_str, model: model.clone(), extra_flags, @@ -954,7 +859,7 @@ fn register_acp_mcp_session( // Write instruction file for agent context (not MCP config) if let Some(filename) = agent_instruction_filename(agent_name) { - write_instruction_file(cwd, filename, session_mode); + write_instruction_file(cwd, filename); } let mut guard = mcp_state.blocking_lock(); @@ -1338,7 +1243,7 @@ pub struct AcpTaskSessionOpts<'a> { /// Whether this session runs in trust mode (autonomous permission handling). /// When true, the ACP permission policy engine uses the trust mode policy /// (auto_approve / deny_writes) instead of normal rule evaluation. - /// Typically enabled for continuous mode auto-launch queues. + /// Typically enabled for queue mode auto-launch queues. pub is_trust_mode: bool, } @@ -2128,7 +2033,7 @@ pub fn start_breakdown_session( } let launch_config = AgentLaunchConfig { - system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some(), Some("breakdown")), + system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some()), prompt: user_prompt_str, model: model.clone(), extra_flags, @@ -2359,7 +2264,7 @@ pub fn relaunch_session( let relaunch_prompt = Some(interpolate_vars(&template.prompt, &vars)); let launch_config = AgentLaunchConfig { - system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some(), Some(old.mode.as_str())), + system_prompt: mcp_system_prompt(adapter.as_ref(), mcp_conn.is_some()), prompt: relaunch_prompt, model: model.clone(), extra_flags, @@ -2644,8 +2549,8 @@ pub fn stop_session( // 5. Update status to Stopped db::sessions::update_status(conn, session_id, SessionStatus::Stopped)?; - // 6. Pause continuous mode if this session was manually stopped - continuous::handle_manual_stop(app, session_id); + // 6. Pause queue mode if this session was manually stopped + queue::handle_manual_stop(app, session_id); // 7. Emit events let updated = db::sessions::get(conn, session_id)? @@ -2670,7 +2575,7 @@ pub fn stop_session( /// the frontend refreshes and re-fetches the session before `remove_session` /// can delete it. /// -/// All cleanup logic (PTY kill, MCP, worktree, continuous mode) mirrors +/// All cleanup logic (PTY kill, MCP, worktree, queue mode) mirrors /// `stop_session` exactly — the only difference is that we delete from DB /// and emit `session-removed` instead of updating status + emitting `session-stopped`. pub fn stop_and_remove_session( @@ -2744,8 +2649,8 @@ pub fn stop_and_remove_session( } } - // 5. Pause continuous mode if this session was manually stopped - continuous::handle_manual_stop(app, session_id); + // 5. Pause queue mode if this session was manually stopped + queue::handle_manual_stop(app, session_id); // 6. Delete from DB (full removal, not just status update) db::sessions::delete(conn, session_id)?; @@ -2802,7 +2707,7 @@ mod tests { #[test] fn upsert_mcp_section_appends_to_empty() { - let section = mcp_instruction_section(Some("task")); + let section = mcp_instruction_section(); let result = upsert_mcp_section("", §ion); assert!(result.contains(MCP_INSTRUCTION_MARKER_START)); assert!(result.contains(MCP_INSTRUCTION_MARKER_END)); @@ -2811,7 +2716,7 @@ mod tests { #[test] fn upsert_mcp_section_appends_to_existing() { let existing = "# My Project\n\nSome content here.\n"; - let section = mcp_instruction_section(Some("task")); + let section = mcp_instruction_section(); let result = upsert_mcp_section(existing, §ion); assert!(result.starts_with("# My Project")); assert!(result.contains(MCP_INSTRUCTION_MARKER_START)); @@ -2823,11 +2728,11 @@ mod tests { "# Header\n\n{}\nold content\n{}\n\n# Footer\n", MCP_INSTRUCTION_MARKER_START, MCP_INSTRUCTION_MARKER_END ); - let section = mcp_instruction_section(Some("task")); + let section = mcp_instruction_section(); let result = upsert_mcp_section(&existing, §ion); assert!(result.contains("# Header")); assert!(result.contains("# Footer")); - assert!(result.contains("report_status")); + assert!(result.contains("get_instructions")); // Old markers replaced, only one start marker assert_eq!(result.matches(MCP_INSTRUCTION_MARKER_START).count(), 1); } @@ -2835,11 +2740,12 @@ mod tests { #[test] fn write_instruction_file_creates_new() { let tmp = tempfile::tempdir().unwrap(); - write_instruction_file(tmp.path(), "CLAUDE.md", Some("task")); + write_instruction_file(tmp.path(), "CLAUDE.md"); let path = tmp.path().join("CLAUDE.md"); assert!(path.exists()); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains(MCP_INSTRUCTION_MARKER_START)); + assert!(content.contains("get_instructions")); } #[test] @@ -2847,7 +2753,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let path = tmp.path().join("CLAUDE.md"); std::fs::write(&path, "# My Project Instructions\n").unwrap(); - write_instruction_file(tmp.path(), "CLAUDE.md", Some("task")); + write_instruction_file(tmp.path(), "CLAUDE.md"); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains("# My Project Instructions")); assert!(content.contains(MCP_INSTRUCTION_MARKER_START)); diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index f0257f9..31c1f38 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -10,14 +10,11 @@ import { Layers, List, ListChecks, - Loader2, Paperclip, Rows3, - SendIcon, Sparkles, SquareIcon, Terminal, - X, } from "lucide-react"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -42,7 +39,6 @@ import { type PromptInputMessage, } from "@/components/ai-elements/prompt-input"; -import { Shimmer } from "@/components/ai-elements/shimmer"; import { usePersistedBoolean, usePersistedString } from "../../hooks/usePersistedState"; import { useAppStore } from "../../store/appStore"; import { cn } from "@/lib/utils"; @@ -174,19 +170,17 @@ export default React.memo(function ChatInput({ return () => { cancelled = true; }; }, [sessionId, disabled]); - // ── Stop & Send confirmation state ── - const [showStopConfirm, setShowStopConfirm] = useState(false); - /** True while we're waiting for the agent to stop (after Stop or Stop & Send). */ - const [isStopping, setIsStopping] = useState(false); - const pendingMessageRef = useRef(null); + // ── Cancel safety timeout ── + // If the backend doesn't emit acp-prompt-complete within this window after + // a cancel, we force-clear promptPending so the UI never gets stuck. + const cancelTimeoutRef = useRef | null>(null); - // Clear confirmation / stopping state when promptPending becomes false (agent finished/cancelled) + // Cleanup timeout on unmount useEffect(() => { - if (!promptPending) { - if (showStopConfirm) setShowStopConfirm(false); - if (isStopping) setIsStopping(false); - } - }, [promptPending, showStopConfirm, isStopping]); + return () => { + if (cancelTimeoutRef.current) clearTimeout(cancelTimeoutRef.current); + }; + }, []); // ── Suggestion overlay state ── const [suggestionType, setSuggestionType] = useState<"slash" | "file" | null>( @@ -197,7 +191,6 @@ export default React.memo(function ChatInput({ const [fileSuggestions, setFileSuggestions] = useState([]); const textareaRef = useRef(null); const suppressNextChange = useRef(false); - const [isFocused, setIsFocused] = useState(false); // ── Initial text pre-fill ── useEffect(() => { @@ -414,7 +407,7 @@ export default React.memo(function ChatInput({ [suggestionType, suggestions.length, selectedIdx, applySuggestion, closeSuggestions], ); - /** Actually send a message (no guards — called after confirmation or when agent is idle). */ + /** Actually send a message to the agent. */ const doSend = useCallback( async (message: PromptInputMessage) => { const text = message.text.trim(); @@ -468,120 +461,88 @@ export default React.memo(function ChatInput({ [sessionId, addAcpUserMessage, setAcpPromptPending, setMcpStatus, setAcpDraftText, closeSuggestions], ); - const handleSubmit = useCallback( + /** Cancel current agent work, wait for idle, then send the new message. */ + const interruptAndSend = useCallback( async (message: PromptInputMessage) => { - const text = message.text.trim(); - const hasFiles = message.files && message.files.length > 0; - if ((!text && !hasFiles) || disabled) return; - - // Block while we're waiting for a stop/stop-and-send to complete - if (isStopping) return; - - // If agent is currently working, show confirmation instead of sending - if (promptPending) { - pendingMessageRef.current = message; - setShowStopConfirm(true); - return; + // Fire cancel — the backend will signal acp-prompt-complete/error which + // clears promptPending via clearAcpPromptIfCurrent. We don't wait for + // that event; instead we poll briefly so the new doSend() gets a clean + // turn counter, then send regardless after the timeout. + try { + await invoke("cancel_acp_session", { sessionId }); + } catch (e) { + console.error("Failed to cancel ACP session:", e); } - doSend(message); - }, - [disabled, promptPending, isStopping, doSend], - ); - - /** User confirmed "Stop & Send" — cancel the agent, queue the message, then send. */ - const handleStopAndSend = useCallback(async () => { - const message = pendingMessageRef.current; - if (!message) return; - - setShowStopConfirm(false); - pendingMessageRef.current = null; - setIsStopping(true); - - // Cancel the current agent work - try { - await invoke("cancel_acp_session", { sessionId }); - } catch (e) { - console.error("Failed to cancel ACP session:", e); - } - - // Wait for promptPending to clear (cancel triggers acp-prompt-complete/error event). - // Timeout after 5s to avoid polling forever if the cancel event is lost. - const waitForIdle = () => - new Promise((resolve) => { + // Wait for promptPending to clear (up to 5s). If the cancel event is + // lost we force-clear and send anyway — the user's intent is unambiguous. + await new Promise((resolve) => { const deadline = Date.now() + 5000; const check = () => { const pending = useAppStore.getState().acpPromptPending[sessionId] ?? false; if (!pending || Date.now() >= deadline) { + if (pending) { + console.warn("[ChatInput] Cancel timeout — force-clearing promptPending"); + setAcpPromptPending(sessionId, false); + } resolve(); } else { setTimeout(check, 50); } }; - // Start checking after a small delay to let the cancel propagate setTimeout(check, 100); }); - await waitForIdle(); - setIsStopping(false); - doSend(message); - }, [sessionId, doSend]); + doSend(message); + }, + [sessionId, doSend, setAcpPromptPending], + ); + + const handleSubmit = useCallback( + async (message: PromptInputMessage) => { + const text = message.text.trim(); + const hasFiles = message.files && message.files.length > 0; + if ((!text && !hasFiles) || disabled) return; - /** User dismissed the confirmation bar. */ - const handleDismissStopConfirm = useCallback(() => { - setShowStopConfirm(false); - pendingMessageRef.current = null; - textareaRef.current?.focus(); - }, []); + // If agent is currently working, interrupt and send the new message + // immediately — no confirmation needed (matches Zed's interrupt-and-send). + if (promptPending) { + interruptAndSend(message); + return; + } + doSend(message); + }, + [disabled, promptPending, doSend, interruptAndSend], + ); + + /** Stop the agent's current work without sending a new message. */ const handleStop = useCallback(async () => { - setShowStopConfirm(false); - pendingMessageRef.current = null; - setIsStopping(true); try { await invoke("cancel_acp_session", { sessionId }); } catch (e) { console.error("Failed to cancel ACP session:", e); } - // isStopping clears when promptPending becomes false - }, [sessionId]); - const chatStatus = disabled - ? promptPending - ? "streaming" - : "ready" - : promptPending - ? "streaming" - : "ready"; + // Safety timeout: if promptPending doesn't clear within 5s (lost event), + // force-clear it so the UI never gets stuck in a "stopping" state. + if (cancelTimeoutRef.current) clearTimeout(cancelTimeoutRef.current); + cancelTimeoutRef.current = setTimeout(() => { + const pending = useAppStore.getState().acpPromptPending[sessionId] ?? false; + if (pending) { + console.warn("[ChatInput] Cancel safety timeout — force-clearing promptPending"); + setAcpPromptPending(sessionId, false); + } + cancelTimeoutRef.current = null; + }, 5000); + }, [sessionId, setAcpPromptPending]); + + const chatStatus = promptPending ? "streaming" : "ready"; return (
- {/* Stop & Send confirmation bar */} - {showStopConfirm && ( -
- - Agent is working. Stop and send your message? - - - -
- )} - {/* Suggestion overlay */} - {suggestionType && suggestions.length > 0 && !showStopConfirm && ( + {suggestionType && suggestions.length > 0 && ( setIsFocused(true)} - onBlur={() => setIsFocused(false)} /> - {/* Shimmer working indicator — positioned after the textarea, same visual position as placeholder */} - {promptPending && !placeholderOverride && !isFocused && !draftText && ( -
- - - {isStopping ? "Stopping agent…" : "Agent is working..."} - -
- )} {/* Attachment actions (left side) */}
@@ -712,8 +662,7 @@ export default React.memo(function ChatInput({ onStop={handleStop} variant="destructive" size="icon-sm" - disabled={isStopping} - className={isStopping ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} + className="cursor-pointer" > diff --git a/src/components/Chat/ChatPane.tsx b/src/components/Chat/ChatPane.tsx index bf6a5d9..4b58fab 100644 --- a/src/components/Chat/ChatPane.tsx +++ b/src/components/Chat/ChatPane.tsx @@ -67,7 +67,10 @@ export default React.memo(function ChatPane({ ); const isStarting = sessionStatus === "starting"; const isRunning = sessionStatus === "running"; - const inputDisabled = !isRunning; + // Input is only disabled when the session is not active (ended/error/stopped). + // During "running" the user can type freely; submitting while the agent is + // working will interrupt-and-send (cancel current work, then send new message). + const inputDisabled = !isRunning && !isStarting; // Edit & resend state const [editResendText, setEditResendText] = useState(); @@ -93,11 +96,10 @@ export default React.memo(function ChatPane({ const isEmpty = timeline.length === 0; - // Check if the agent is actively working (for working indicator) - const isAgentWorking = promptPending && ( - isEmpty || - (entries.length > 0 && entries[entries.length - 1].type === "user-message") - ); + // Working indicator stays visible for the entire generation duration (like + // Zed/t3-code), sitting at the bottom of the timeline as a persistent + // "still working" signal even while content streams above it. + const isAgentWorking = promptPending; return (
@@ -157,7 +159,7 @@ export default React.memo(function ChatPane({ return null; })} - {/* Working indicator — shown when agent is active but hasn't produced anything yet */} + {/* Working indicator — visible for the entire turn while agent is generating */} {isAgentWorking && } )} @@ -168,17 +170,20 @@ export default React.memo(function ChatPane({
- + {/* Footer — input zone with distinct background */} +
+ - + - + +
); }); diff --git a/src/components/Chat/ChatPlanQueue.tsx b/src/components/Chat/ChatPlanQueue.tsx index 61665bc..0762d0f 100644 --- a/src/components/Chat/ChatPlanQueue.tsx +++ b/src/components/Chat/ChatPlanQueue.tsx @@ -1,20 +1,37 @@ -import { ListChecks } from "lucide-react"; -import React, { useMemo } from "react"; +import { CheckCircle2, Circle, ListChecks, Loader2 } from "lucide-react"; +import React, { useEffect, useMemo, useRef } from "react"; import { - Queue, - QueueItem, - QueueItemContent, - QueueItemIndicator, - QueueList, - QueueSection, - QueueSectionContent, - QueueSectionLabel, - QueueSectionTrigger, -} from "@/components/ai-elements/queue"; + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; import { useAppStore } from "../../store/appStore"; +import type { AcpPlanEntry } from "../../types"; + +// ── Timing tracker ── + +/** Frontend-only timing: records when each plan entry started and completed. */ +interface EntryTiming { + startedAt?: number; + completedAt?: number; +} + +function formatDuration(ms: number): string { + if (ms < 1000) return "<1s"; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +// ── Component ── + interface ChatPlanQueueProps { sessionId: string; } @@ -24,6 +41,32 @@ export default React.memo(function ChatPlanQueue({ }: ChatPlanQueueProps) { const planEntries = useAppStore((s) => s.acpPlans[sessionId]); + // Track per-entry timing by comparing status transitions across renders + const timingsRef = useRef>(new Map()); + + useEffect(() => { + if (!planEntries) return; + const now = Date.now(); + const timings = timingsRef.current; + + for (const entry of planEntries) { + const existing = timings.get(entry.id); + + if (entry.status === "in_progress") { + if (!existing?.startedAt) { + timings.set(entry.id, { ...existing, startedAt: now }); + } + } else if (entry.status === "completed") { + if (!existing?.completedAt) { + timings.set(entry.id, { + startedAt: existing?.startedAt ?? now, + completedAt: now, + }); + } + } + } + }, [planEntries]); + const completedCount = useMemo( () => (planEntries ?? []).filter((e) => e.status === "completed").length, [planEntries], @@ -35,63 +78,102 @@ export default React.memo(function ChatPlanQueue({ return (
- - - - } - /> -
-
+ + {/* Container — matches PromptInput's border-input styling with glass effect */} +
+ {/* Header / trigger */} + + + Plan + + {/* Progress bar + count — right side */} +
+
- + {completedCount}/{planEntries.length}
- - - - {planEntries.map((entry) => ( - -
- - - {entry.title} - -
-
- ))} -
-
- - + + + {/* Collapsible task list */} + + +
    + {planEntries.map((entry) => ( + + ))} +
+
+
+
+
); }); + +// ── Plan item ── + +function PlanItem({ + entry, + timing, +}: { + entry: AcpPlanEntry; + timing?: EntryTiming; +}) { + const isCompleted = entry.status === "completed"; + const isInProgress = entry.status === "in_progress"; + + // Compute elapsed duration for completed items + const durationLabel = useMemo(() => { + if (!isCompleted || !timing?.startedAt || !timing?.completedAt) return null; + const ms = timing.completedAt - timing.startedAt; + // Only show if it took at least 1 second (avoid noise) + return ms >= 1000 ? formatDuration(ms) : null; + }, [isCompleted, timing?.startedAt, timing?.completedAt]); + + return ( +
  • + + + {entry.title} + + {durationLabel && ( + + {durationLabel} + + )} +
  • + ); +} + +function PlanItemIcon({ status }: { status: string }) { + switch (status) { + case "completed": + return ; + case "in_progress": + return ; + default: + return ; + } +} diff --git a/src/components/Chat/ChatView.tsx b/src/components/Chat/ChatView.tsx index d0e65d7..f139224 100644 --- a/src/components/Chat/ChatView.tsx +++ b/src/components/Chat/ChatView.tsx @@ -3,21 +3,21 @@ import { AlertTriangle, Loader2, MessageCircle, - Plus, + Send, X, } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useProjectAccentColor } from "../../hooks/useProjectAccentColor"; import { formatErrorWithHint } from "../../lib/errorMessages"; import { useAppStore } from "../../store/appStore"; -import AgentCardGrid from "../Launchers/AgentCardGrid"; +import AgentModelPicker from "../Launchers/AgentModelPicker"; import ConfirmDialog from "../Review/ConfirmDialog"; import { Button } from "../ui/orecus.io/components/enhanced-button"; import ChatPane from "./ChatPane"; import ThreadStatusBadge from "./ThreadStatusBadge"; -import type { Session } from "../../types"; +import type { AgentInfo, Session } from "../../types"; /** * ChatView — project-scoped chat view. @@ -34,7 +34,10 @@ const ChatView = memo(function ChatView() { const removeBackgroundTask = useAppStore((s) => s.removeBackgroundTask); const [selectedAgentName, setSelectedAgentName] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [userPrompt, setUserPrompt] = useState(""); const [error, setError] = useState(null); + const textareaRef = useRef(null); // ACP-capable agents only const acpAgents = useMemo( @@ -42,6 +45,11 @@ const ChatView = memo(function ChatView() { [agents], ); + const acpFilter = useCallback( + (a: AgentInfo) => a.supports_acp, + [], + ); + // Default to first ACP agent useEffect(() => { if (acpAgents.length > 0 && !selectedAgentName) { @@ -67,6 +75,11 @@ const ChatView = memo(function ChatView() { const handleAgentSelect = useCallback((name: string) => { setSelectedAgentName(name); + setSelectedModel(""); + }, []); + + const handleModelSelect = useCallback((model: string) => { + setSelectedModel(model); }, []); const handleStartChat = useCallback(async () => { @@ -79,6 +92,8 @@ const ChatView = memo(function ChatView() { await invoke("start_chat_session", { projectId: activeProjectId, agentName: selectedAgentName, + model: selectedModel || null, + userPrompt: userPrompt.trim() || null, }); } catch (err) { setError(formatErrorWithHint(err, "agent-launch")); @@ -89,6 +104,8 @@ const ChatView = memo(function ChatView() { }, [ activeProjectId, selectedAgentName, + selectedModel, + userPrompt, launching, addBackgroundTask, removeBackgroundTask, @@ -114,11 +131,11 @@ const ChatView = memo(function ChatView() { if (chatSession) { return (
    {/* Minimal toolbar */} -
    +
    Project Chat @@ -197,20 +214,17 @@ const ChatView = memo(function ChatView() {

    - {/* Agent selector */} + {/* Agent + Model picker */} {acpAgents.length > 0 ? ( -
    - - !a.installed || !a.acp_installed} - showStatus - /> -
    + ) : (
    )} - {/* Start new chat */} - + {/* Prompt textarea + Start button */} +
    +
    +