Auto set task as subtask based on Git branch (vibe-kanban) (#1468)

* All implementation steps are complete. Here's a summary of what was implemented:

Implemented auto-linking of subtasks when creating tasks with 6 changes:

1. **`crates/db/src/models/task_attempt.rs`** - Added `find_by_branch()` query to look up task attempts by branch name within a project

2. **`crates/db/src/models/task.rs`** - Added:
   - `Clone` derive to `CreateTask` struct
   - `with_parent_from_branch()` helper method on `CreateTask` for fluent branch-based parent resolution
   - `update_parent_task_attempt()` method on `Task` to update parent linkage for existing tasks

3. **`crates/server/src/routes/tasks.rs`** - Updated `create_task_and_start()` to use `with_parent_from_branch()` helper before creating the task

4. **`crates/server/src/routes/task_attempts.rs`** - Updated `create_task_attempt()` to link existing tasks to parent attempts when `base_branch` matches an existing attempt's branch

5. **`crates/server/src/mcp/task_server.rs`** - Updated MCP `create_task` to automatically set `parent_task_attempt` from the current context's `attempt_id`

6. **`crates/db/.sqlx/`** - Updated SQLx query cache with the new queries

* Cleanup script changes for task attempt 3bcb8f0d-6655-4b34-9cd2-c3c731795b78

* All implementation steps are complete. Here's a summary of what was implemented:

## Summary

Auto-linking of subtasks when creating tasks is now implemented for all three scenarios:

1. **MCP create_task** (`task_server.rs:404-432`): When an AI agent creates a task via MCP while working in a worktree, the new task automatically becomes a subtask of the current attempt (uses `McpContext.attempt_id`)

2. **Branch-based targeting**:
   - `create_task_and_start` (`tasks.rs`): Uses `with_parent_from_branch` helper to auto-link when `base_branch` matches an existing attempt
   - `create_task_attempt` (`task_attempts.rs`): Updates existing task's parent when creating an attempt with a matching `base_branch`

3. **Rebase** (`task_attempts.rs:1249-1257`): When rebasing a task attempt onto another attempt's branch, the task's `parent_task_attempt` is updated to point to that attempt

### Files modified:
- `crates/db/src/models/task_attempt.rs` - Added `find_by_branch` query
- `crates/db/src/models/task.rs` - Added `Clone` derive, `with_parent_from_branch` helper, `update_parent_task_attempt` method
- `crates/server/src/routes/tasks.rs` - Updated `create_task_and_start`
- `crates/server/src/routes/task_attempts.rs` - Updated `create_task_attempt` and `rebase_task_attempt`
- `crates/server/src/mcp/task_server.rs` - Updated MCP `create_task`

* Cleanup script changes for task attempt 3bcb8f0d-6655-4b34-9cd2-c3c731795b78

* All 8 steps are now complete. Here's a summary of the implementation:

## Summary

Added auto-linking of subtasks in the `change_target_branch` endpoint (`task_attempts.rs:1047-1055`). When a user changes a task attempt's target branch to match another attempt's branch, the task automatically becomes a subtask of that attempt.

The full implementation now covers all 4 scenarios:

1. **MCP create_task** - Links via `McpContext.attempt_id`
2. **Branch-based targeting** - Links in `create_task_and_start` and `create_task_attempt` via `with_parent_from_branch`
3. **Rebase** - Links in `rebase_task_attempt` after successful git rebase
4. **Change target branch** - Links in `change_target_branch` after updating target_branch (newly added)

* Done. Removed `from_title_description` helper from `task.rs` and updated `task_server.rs` to construct `CreateTask` directly with all fields set correctly. All checks pass.
This commit is contained in:
Louis Knight-Webb
2025-12-09 11:54:34 +00:00
committed by GitHub
parent aee6ac35b8
commit d8eeab628c
7 changed files with 212 additions and 41 deletions

View File

@@ -0,0 +1,74 @@
{
"db_name": "SQLite",
"query": "SELECT ta.id as \"id!: Uuid\", ta.task_id as \"task_id!: Uuid\",\n ta.container_ref, ta.branch, ta.target_branch,\n ta.executor as \"executor!\", ta.worktree_deleted as \"worktree_deleted!: bool\",\n ta.setup_completed_at as \"setup_completed_at: DateTime<Utc>\",\n ta.created_at as \"created_at!: DateTime<Utc>\",\n ta.updated_at as \"updated_at!: DateTime<Utc>\"\n FROM task_attempts ta\n JOIN tasks t ON ta.task_id = t.id\n WHERE t.project_id = ? AND ta.branch = ?\n ORDER BY ta.created_at DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id!: Uuid",
"ordinal": 0,
"type_info": "Blob"
},
{
"name": "task_id!: Uuid",
"ordinal": 1,
"type_info": "Blob"
},
{
"name": "container_ref",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "branch",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "target_branch",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "executor!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "worktree_deleted!: bool",
"ordinal": 6,
"type_info": "Bool"
},
{
"name": "setup_completed_at: DateTime<Utc>",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "created_at!: DateTime<Utc>",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "updated_at!: DateTime<Utc>",
"ordinal": 9,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
true,
false,
true,
false,
false,
true,
false,
true,
false,
false
]
},
"hash": "3893e8aa7f30ac45d817eb19bbea6c4afc9ab8ad001039593350b658ea970f3f"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE tasks SET parent_task_attempt = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "8156a3515451357855aa31bdc6c076cfa8c00e33b6e70defd94aab04c5a42758"
}

View File

@@ -66,7 +66,7 @@ pub struct TaskRelationships {
pub children: Vec<Task>, // Tasks created by this attempt
}
#[derive(Debug, Serialize, Deserialize, TS)]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
pub struct CreateTask {
pub project_id: Uuid,
pub title: String,
@@ -78,22 +78,6 @@ pub struct CreateTask {
}
impl CreateTask {
pub fn from_title_description(
project_id: Uuid,
title: String,
description: Option<String>,
) -> Self {
Self {
project_id,
title,
description,
status: Some(TaskStatus::Todo),
parent_task_attempt: None,
image_ids: None,
shared_task_id: None,
}
}
pub fn from_shared_task(
project_id: Uuid,
title: String,
@@ -111,6 +95,23 @@ impl CreateTask {
shared_task_id: Some(shared_task_id),
}
}
/// Resolves parent_task_attempt from base_branch if not already set.
/// If base_branch matches an existing task attempt's branch, sets that as parent.
pub async fn with_parent_from_branch(
mut self,
pool: &SqlitePool,
base_branch: &str,
) -> Result<Self, sqlx::Error> {
// Only resolve if parent not already set (explicit parent takes precedence)
if self.parent_task_attempt.is_none()
&& let Some(parent) =
TaskAttempt::find_by_branch(pool, self.project_id, base_branch).await?
{
self.parent_task_attempt = Some(parent.id);
}
Ok(self)
}
}
#[derive(Debug, Serialize, Deserialize, TS)]
@@ -348,6 +349,22 @@ ORDER BY t.created_at DESC"#,
Ok(())
}
/// Update the parent_task_attempt field for a task
pub async fn update_parent_task_attempt(
pool: &SqlitePool,
task_id: Uuid,
parent_task_attempt: Option<Uuid>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
"UPDATE tasks SET parent_task_attempt = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1",
task_id,
parent_task_attempt
)
.execute(pool)
.await?;
Ok(())
}
/// Nullify parent_task_attempt for all tasks that reference the given attempt ID
/// This breaks parent-child relationships before deleting a parent task
pub async fn nullify_children_by_attempt_id<'e, E>(

View File

@@ -444,6 +444,33 @@ impl TaskAttempt {
Ok(result.rows_affected())
}
/// Find a task attempt by its branch name within a project.
/// Returns the most recently created attempt if multiple exist with the same branch.
pub async fn find_by_branch(
pool: &SqlitePool,
project_id: Uuid,
branch: &str,
) -> Result<Option<TaskAttempt>, sqlx::Error> {
sqlx::query_as!(
TaskAttempt,
r#"SELECT ta.id as "id!: Uuid", ta.task_id as "task_id!: Uuid",
ta.container_ref, ta.branch, ta.target_branch,
ta.executor as "executor!", ta.worktree_deleted as "worktree_deleted!: bool",
ta.setup_completed_at as "setup_completed_at: DateTime<Utc>",
ta.created_at as "created_at!: DateTime<Utc>",
ta.updated_at as "updated_at!: DateTime<Utc>"
FROM task_attempts ta
JOIN tasks t ON ta.task_id = t.id
WHERE t.project_id = ? AND ta.branch = ?
ORDER BY ta.created_at DESC
LIMIT 1"#,
project_id,
branch
)
.fetch_optional(pool)
.await
}
pub async fn resolve_container_ref(
pool: &SqlitePool,
container_ref: &str,

View File

@@ -470,16 +470,22 @@ impl TaskServer {
};
let url = self.url("/api/tasks");
// Get parent_task_attempt from context if available (auto-link subtasks)
let parent_task_attempt = self.context.as_ref().map(|ctx| ctx.attempt_id);
let create_payload = CreateTask {
project_id,
title,
description: expanded_description,
status: Some(TaskStatus::Todo),
parent_task_attempt,
image_ids: None,
shared_task_id: None,
};
let task: Task = match self
.send_json(
self.client
.post(&url)
.json(&CreateTask::from_title_description(
project_id,
title,
expanded_description,
)),
)
.send_json(self.client.post(&url).json(&create_payload))
.await
{
Ok(t) => t,

View File

@@ -130,11 +130,20 @@ pub async fn create_task_attempt(
State(deployment): State<DeploymentImpl>,
Json(payload): Json<CreateTaskAttemptBody>,
) -> Result<ResponseJson<ApiResponse<TaskAttempt>>, ApiError> {
let pool = &deployment.db().pool;
let executor_profile_id = payload.get_executor_profile_id();
let task = Task::find_by_id(&deployment.db().pool, payload.task_id)
let task = Task::find_by_id(pool, payload.task_id)
.await?
.ok_or(SqlxError::RowNotFound)?;
// Link task to parent attempt if base_branch matches an existing attempt's branch
if task.parent_task_attempt.is_none()
&& let Some(parent) =
TaskAttempt::find_by_branch(pool, task.project_id, &payload.base_branch).await?
{
Task::update_parent_task_attempt(pool, task.id, Some(parent.id)).await?;
}
let attempt_id = Uuid::new_v4();
let git_branch_name = deployment
.container()
@@ -142,7 +151,7 @@ pub async fn create_task_attempt(
.await;
let task_attempt = TaskAttempt::create(
&deployment.db().pool,
pool,
&CreateTaskAttempt {
executor: executor_profile_id.executor,
base_branch: payload.base_branch.clone(),
@@ -1018,17 +1027,13 @@ pub async fn change_target_branch(
let project = Project::find_by_id(&deployment.db().pool, task.project_id)
.await?
.ok_or(ApiError::Project(ProjectError::ProjectNotFound))?;
let pool = &deployment.db().pool;
match deployment
.git()
.check_branch_exists(&project.git_repo_path, &new_target_branch)?
{
true => {
TaskAttempt::update_target_branch(
&deployment.db().pool,
task_attempt.id,
&new_target_branch,
)
.await?;
TaskAttempt::update_target_branch(pool, task_attempt.id, &new_target_branch).await?;
}
false => {
return Ok(ResponseJson(ApiResponse::error(
@@ -1040,6 +1045,17 @@ pub async fn change_target_branch(
)));
}
}
// Link task to parent attempt if new_target_branch matches an existing attempt's branch
if let Some(parent_attempt) =
TaskAttempt::find_by_branch(pool, project.id, &new_target_branch).await?
{
// Only update if different from current parent
if task.parent_task_attempt != Some(parent_attempt.id) {
Task::update_parent_task_attempt(pool, task.id, Some(parent_attempt.id)).await?;
}
}
let status = deployment.git().get_branch_status(
&project.git_repo_path,
&task_attempt.branch,
@@ -1239,6 +1255,16 @@ pub async fn rebase_task_attempt(
};
}
// Link task to parent attempt if new_base_branch matches an existing attempt's branch
if let Some(parent_attempt) =
TaskAttempt::find_by_branch(pool, ctx.project.id, &new_base_branch).await?
{
// Only update if different from current parent
if task.parent_task_attempt != Some(parent_attempt.id) {
Task::update_parent_task_attempt(pool, task.id, Some(parent_attempt.id)).await?;
}
}
deployment
.track_if_analytics_allowed(
"task_attempt_rebased",

View File

@@ -147,11 +147,20 @@ pub async fn create_task_and_start(
State(deployment): State<DeploymentImpl>,
Json(payload): Json<CreateAndStartTaskRequest>,
) -> Result<ResponseJson<ApiResponse<TaskWithAttemptStatus>>, ApiError> {
let task_id = Uuid::new_v4();
let task = Task::create(&deployment.db().pool, &payload.task, task_id).await?;
let pool = &deployment.db().pool;
if let Some(image_ids) = &payload.task.image_ids {
TaskImage::associate_many_dedup(&deployment.db().pool, task.id, image_ids).await?;
// Resolve parent from branch if applicable
let task_payload = payload
.task
.clone()
.with_parent_from_branch(pool, &payload.base_branch)
.await?;
let task_id = Uuid::new_v4();
let task = Task::create(pool, &task_payload, task_id).await?;
if let Some(image_ids) = &task_payload.image_ids {
TaskImage::associate_many_dedup(pool, task.id, image_ids).await?;
}
deployment
@@ -161,7 +170,7 @@ pub async fn create_task_and_start(
"task_id": task.id.to_string(),
"project_id": task.project_id,
"has_description": task.description.is_some(),
"has_images": payload.task.image_ids.is_some(),
"has_images": task_payload.image_ids.is_some(),
}),
)
.await;
@@ -172,7 +181,7 @@ pub async fn create_task_and_start(
.await;
let task_attempt = TaskAttempt::create(
&deployment.db().pool,
pool,
&CreateTaskAttempt {
executor: payload.executor_profile_id.executor,
base_branch: payload.base_branch,
@@ -200,7 +209,7 @@ pub async fn create_task_and_start(
)
.await;
let task = Task::find_by_id(&deployment.db().pool, task.id)
let task = Task::find_by_id(pool, task.id)
.await?
.ok_or(ApiError::Database(SqlxError::RowNotFound))?;